Skip to content

feat: add injectable time and UUID providers for timestamps and ids - #1255

Merged
copybara-service[bot] merged 1 commit into
google:mainfrom
DABH:deterministic-clock-uuid-providers
Oct 7, 2026
Merged

copybara-service[bot] merged 1 commit into
google:mainfrom
DABH:deterministic-clock-uuid-providers

Conversation

@DABH

@DABH DABH commented Jun 9, 2026 •

Copy link
Copy Markdown
Contributor

Link to Issue or Description of Change

Problem:
ADK generates timestamps and IDs by calling Instant.now() and UUID.randomUUID() directly. There are use cases where it's useful to have custom clock and UUID providers, such as when you want to make runs reproducible/deterministic (for example a durable-execution host that replays an invocation and must reproduce the same event ids and timestamps).

adk-python already solves this with platform.time / platform.uuid (google/adk-python#4200) and adk-go with platform.Now / platform.NewUUID (google/adk-go#964). Closely inspired by those, we propose adding the same functionality to adk-java.

Solution:
Two seams, configured once on the Runner and carried on every InvocationContext it creates:

  • Time comes from the JDK's java.time.InstantSource (Java 17; already used in core). Runner.builder().instantSource(...) accepts any Clock, InstantSource.fixed(...), or lambda; the default is InstantSource.system().
  • IDs come from a new functional interface com.google.adk.platform.UuidProvider (String newUuid(), default SYSTEM backed by UUID.randomUUID()), configured with Runner.builder().uuidProvider(...). There is no JDK equivalent, so this is the only new type.
  • Passing null to either setter (on Runner.Builder or InvocationContext.Builder) selects the default, the same rule as scheduler(null) from feat: add an injectable scheduler for ADK's internal fan-out #1587, so a nested run started from a mocked or partially configured context keeps working.

Both default to today's wall-clock / random behavior, so this change is fully backwards-compatible.

The providers are threaded as data through InvocationContext (instantSource(), uuidProvider(), and the now() / newUuid() conveniences) rather than stored in an ambient ThreadLocal. ADK's core flow hops threads via RxJava (e.g. BaseLlmFlow observes on Schedulers.io() / the agent executor), and events are built downstream on those worker threads. A ThreadLocal would silently fall back to the system providers after a thread hop, failing open with no error. Carrying the providers on the InvocationContext (which is already captured in the RxJava lambdas and passed down the chain) makes them visible on whatever thread builds an event. This mirrors adk-go, which diverged from adk-python's contextvar mechanism for the same concurrency reason.

The Runner injects the providers into the default InMemorySessionService it constructs, into the LlmEventSummarizer it builds for event compaction, and into every InvocationContext it creates for new, resumed, and live invocations. AgentTool builds the runner it nests with Runner.builder() carrying the parent invocation's scheduler (from #1587) and providers (the caller's RunConfig handling from main is unchanged). Two builder behaviors are preserved as on main: the default session service is created once per Runner.Builder (now lazily, on the first build(), so it uses the configured providers) and shared by every runner built from it, and InvocationContext.Builder mints its default invocation id once per builder, so repeated build() calls share it. Runner.Builder.sessionService(null) still fails in build() with IllegalStateException, not in the setter.

Every generated id and timestamp on the Runner → agent → flow → session-event path now derives from the in-scope providers:

  • InvocationContext — invocation id (minted from the configured provider), plus now() / newUuid() / instantSource() / uuidProvider() accessors.
  • Runner — invocation id, user message events, before-run plugin events, and the content-less state-delta event minted when resuming an invocation.
  • BaseAgent — agent-callback events and resumability checkpoint / end-of-agent events.
  • BaseLlmFlow, Functions, OutputSchema, CodeExecution, FunctionCallIds — model-response event ids and timestamps, replayed function-call re-ids, tool-response and tool-confirmation events, the client function-call ids the flow mints (including the adk_request_confirmation call id).
  • LlmEventSummarizer — compaction events (constructor injection from the Runner).
  • InMemorySessionService and contrib/firestore-session-service — auto-generated session id and lastUpdateTime (one additional, full-argument constructor each; the existing constructors keep the system defaults). LlmEventSummarizer likewise gains one full-argument constructor; passing a null prompt template selects the default one.
  • a2a — RemoteA2AAgent aggregated events and outbound A2A message ids, ResponseConverter events; EventConverter no longer mints a throwaway event (and id) when rephrasing another agent's output as request parts.
  • contrib/planners — PlannerAgent result event.

Event.generateEventId() and the Event.Builder.build() timestamp default now delegate to UuidProvider.SYSTEM / InstantSource.system(), so ADK's flow and session code has a single source for generated ids and timestamps while the Event API stays unchanged; their Javadoc points custom agents, tools, and plugins at InvocationContext.newUuid() / now().

Visible side effects worth calling out:

  • Event.toBuilder() now always copies the timestamp. It used to drop a timestamp of 0, which existed only to serve a template hack in BaseLlmFlow (now removed) and silently replaced a clock fixed at Instant.EPOCH with the wall clock on every rebuild.
  • Model-response events are timestamped when the response arrives rather than from the pre-call event template (as in adk-python, timestamps are assigned per event rather than inherited from a zeroed template), and events that previously had no id at all (CodeExecution, the tool-confirmation request event) now get one, which also lets them participate in PersistBarrier.
  • new FirestoreSessionService(firestore) now rejects a null client eagerly (it delegates to the full constructor's requireNonNull) instead of failing on first use.
  • BaseLlmFlow no longer re-ids its per-step event template after the model call: nothing read the template at that point, and once routed through the provider that dead draw would have reached it after the step completed (on an RxJava worker thread) and made the id sequence depend on whether tools ran synchronously or asynchronously.

Intentionally left as-is (telemetry or outside the session-event path; happy to follow up if useful): the agentanalytics plugin (TraceManager, PluginState, Parser, BigQueryAgentAnalyticsPlugin), the elapsed-time telemetry in telemetry/Instrumentation, the A2A server-side AgentExecutor protocol ids, the Firestore create token (an idempotency marker, not session data), the client function-call ids that model adapters assign themselves while aggregating a streamed response (Gemini in streaming mode, LangChain4j; model adapters have no InvocationContext), the deploy CLI's default deployment name, the Kotlin interop views (they already carry explicit ids from the Kotlin engine), the wall-clock timestamp that CodeExecutorContext writes into session state during code execution (sweeping it needs an InstantSource on its public constructor; happy to do that as a follow-up), and the id and timestamp ResponseConverter draws for a remote event before main's new unconvertible-part filter decides to drop it (those draws depend only on the peer's response, so replay stays deterministic; converting the parts before building the event is a small follow-up). A scheduler/task-runner seam for the RxJava fan-out (ParallelAgent, parallel tool calls), the Java analog of adk-go's platform.WithTaskRunner (google/adk-go#1050), landed separately in #1587, and the two seams now sit side by side on InvocationContext and Runner.Builder.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

New/updated tests:

  • platform/UuidProviderTest — SYSTEM default + custom-provider behavior.
  • InvocationContextTest — providers default to the system sources, null selects them, they are carried by the builder / toBuilder(), drive now() / newUuid(), and the default invocation id is minted exactly once per builder (repeated build() calls share it; toBuilder() copies it; the pre-existing null-id validation tests are unchanged).
  • EventTest — toBuilder() preserves a zero timestamp.
  • FunctionCallIdsTest / FunctionsTest / OutputSchemaTest — function-call ids derive from the injected provider; handleFunctionCalls (single and merged parallel responses) and OutputSchema.createFinalModelResponseEvent mint their event id and timestamp from the invocation's providers (these ids are re-assigned by BaseLlmFlow in the async step path, so they are pinned at the unit level).
  • InMemorySessionServiceTest — injected providers produce a deterministic session id and lastUpdateTime, combined with DuplicateSessionIdBehavior.REJECT.
  • RunnerTest — the headline guarantee: running the same input twice yields byte-identical event ids, timestamps, and invocation ids; three determinism tests with a clock fixed at Instant.EPOCH and a counter UUID provider cover a tool-call turn, agent callbacks (content and state-delta), and an output-schema agent, asserting for every emitted and persisted event that the timestamp is 0, ids and invocation ids come from the counter, ADK-minted function-call ids carry the counter, and a second run is identical (reverting the BaseAgent callback sites or the Functions/OutputSchema timestamps fails them; the ids those two methods draw are re-assigned by BaseLlmFlow in the async step path and are pinned by the direct FunctionsTest/OutputSchemaTest cases); the default session service is shared by runners built from one builder; sessionService(null) fails in build(), sessionService(null).sessionService(s) builds; instantSource(null) / uuidProvider(null) select the system defaults; the before-run plugin event and runLive events use the runner's providers.
  • RunnerResumabilityTest — checkpoint (end-of-agent) and resume state-delta events use the runner's providers.
  • AgentToolTest — the nested runner inherits the parent invocation's providers, and a Mockito-mocked InvocationContext (null from the unstubbed accessors) still runs with the defaults.
  • LlmEventSummarizerTest — compaction event id and invocation id are two distinct counter draws, the timestamp comes from the injected clock, and a null prompt template selects the default one.
  • CodeExecutionTest — the emitted executable-code, execution-result, and data-file processing events carry the mocked newUuid() / now() values.
  • a2a: RemoteA2AAgentTest, ResponseConverterTest, EventConverterTest — outbound message id and converted events use the invocation's providers; building the request parts draws no ids and rephrased context stays non-partial.
  • contrib/planners: PlannerAgentTest (converted from JUnit 4 to JUnit 5: the module only has the Jupiter engine, so this class never ran before); contrib/firestore-session-service: FirestoreSessionServiceTest — generated session id and update time come from the injected providers.

Local results (./mvnw -Prelease clean package, OpenJDK 17 (Homebrew 17.0.20.1), all modules): BUILD SUCCESS; core: Tests run: 2113, Failures: 0, Errors: 0, Skipped: 24 (the skips are pre-existing); a2a, contrib/planners, contrib/firestore-session-service, dev, tokt and the remaining modules all pass with 0 failures.
git diff --exit-code after the build: clean (formatter made no changes).

Manual End-to-End (E2E) Tests:

This is an in-process library seam with no runtime/config surface to exercise manually. The guarantee is verified by the unit tests above, which assert two independent runs of the same invocation produce identical IDs and timestamps.

Checklist

  • I have read the CONTRIBUTING.md document.
  • My pull request contains a single commit.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

Motivated by maintaining feature parity with adk-python and adk-go. Rebased onto the current main (9f5d5ce, which includes #1587; single commit); the sweep was extended to the call sites main gained since the PR was opened (resumability checkpoints, resume state-delta events, replayed function-call re-ids, the A2A module, PlannerAgent, FirestoreSessionService, AgentTool's nested runner).

@DABH DABH changed the title feat: Add injectable time and UUID providers for deterministic runs feat: Add injectable time and UUID providers Jun 9, 2026
@hemasekhar-p hemasekhar-p self-assigned this Jun 10, 2026
@hemasekhar-p

Copy link
Copy Markdown
Contributor

Hi @DABH, thank you for your contribution! We appreciate you taking the time to submit this pull request. As per contribution policy, please ensure your PR consists of a single commit and i have noticed inconsistency code formatting. Could you please change your commits accordingly and address the code formatting issue?

@hemasekhar-p hemasekhar-p added the waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. label Jun 10, 2026
Comment thread core/src/main/java/com/google/adk/agents/InvocationContext.java
Comment thread core/src/main/java/com/google/adk/flows/llmflows/BaseLlmFlow.java
Comment thread core/src/test/java/com/google/adk/runner/RunnerTest.java Outdated
Comment thread core/src/main/java/com/google/adk/agents/InvocationContext.java
@hemasekhar-p

Copy link
Copy Markdown
Contributor

@DABH, thank you for your quick response. when running a Maven build, some files are modifying due to incorrect formatting. so, use the Maven build to ensure your changes are properly aligned and please make sure your PR contains a single commit.

@hemasekhar-p hemasekhar-p added waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. and removed waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. labels Jun 11, 2026
@DABH
DABH force-pushed the deterministic-clock-uuid-providers branch from d92d033 to 742e700 Compare June 11, 2026 17:47
@DABH

DABH commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @hemasekhar-p — addressed both. I rebased onto the latest main and ran the full Maven build (which runs the bound fmt:format goal); mvn fmt:check now reports 0 non-complying files across all modules. I've also squashed the change into a single commit. Let me know if you still see any files being reformatted on your end.

@DABH

DABH commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Also @hemasekhar-p I made a few improvements to the design/implementation based on @xumaple's feedback (updated the PR description accordingly). Thanks again for the fast review and please just let me know what else I can do to help make this PR mergeable. I appreciate your help and look forward to hearing from you!

@DABH

DABH commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

Hi @hemasekhar-p , any further thoughts here on this PR? The equivalent Go PR got merged so just wanted to follow up. Thanks again!

@DABH
DABH force-pushed the deterministic-clock-uuid-providers branch from 742e700 to 4e43c28 Compare June 30, 2026 07:41
@hemasekhar-p

Copy link
Copy Markdown
Contributor

@DABH, thank you for the updates, To proceed with the review, could you please address merge conflicts?

@hemasekhar-p hemasekhar-p added waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. and removed waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. labels Jul 22, 2026
@DABH
DABH force-pushed the deterministic-clock-uuid-providers branch from 4e43c28 to 84b8c3e Compare July 24, 2026 04:47
@DABH

DABH commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

@hemasekhar-p Thanks, this has been done - can you please review? Thanks again!

@hemasekhar-p

Copy link
Copy Markdown
Contributor

@DABH, thank you for addressing the feedback and resolving the merge conflicts. The PR is currently under review by our team. We will keep you updated if any further changes or any additional information is required. Thank you.

@hemasekhar-p hemasekhar-p added needs review and removed waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. labels Jul 24, 2026
@sangkyoonnam

Copy link
Copy Markdown

I need this for the same reason adk-python and adk-go added their platform seams: replay-safe event IDs and timestamps under a durable-execution host. adk-java is the one runtime without them. The branch conflicts with main again as of 4092a1f, in two import blocks and the Role.USER change in LlmEventSummarizer, and after a local rebase with those resolved the core tests pass, so it should be a short fix on your side. Once it lands I'd like to send the Java equivalent of adk-go's platform.WithTaskRunner (google/adk-go#1050) as a follow-up, unless you have it in flight.

@DABH
DABH force-pushed the deterministic-clock-uuid-providers branch from 84b8c3e to 3e36f14 Compare October 1, 2026 07:27
@DABH DABH changed the title feat: Add injectable time and UUID providers feat: add injectable time and UUID providers for timestamps and ids Oct 1, 2026
@DABH
DABH force-pushed the deterministic-clock-uuid-providers branch from 3e36f14 to ee6bf82 Compare October 1, 2026 07:33
DABH added a commit to DABH/adk-java that referenced this pull request Oct 1, 2026
ADK hard-codes the RxJava IO scheduler wherever it leaves the caller's
thread: ParallelAgent subscribes each sub-agent on Schedulers.io(), tool
calls in ToolExecutionMode.PARALLEL_SUBSCRIBE are subscribed on it unless
the LlmAgent has an executor, and the live send loop is started on it. An
embedding application has no way to influence that fan-out, for example
to cap it, to run it on an existing worker pool, or to keep every ADK
task on a dispatcher it controls, which a durable-execution host must.
adk-go exposes this control point as platform.WithTaskRunner /
platform.RunTasks (google/adk-go#1050).

Add the Java analog as an RxJava Scheduler carried on InvocationContext.
Runner.builder().scheduler(...) sets it once, every InvocationContext the
runner creates carries it (and toBuilder() copies it), and
InvocationContext.scheduler() returns it or Schedulers.io() when none is
configured, so the default behavior is unchanged. ParallelAgent uses the
invocation's scheduler unless its own builder set one (its default moves
from an eager Schedulers.io() to "inherit"), Functions resolves the
PARALLEL_SUBSCRIBE worker from the LlmAgent executor and then the
invocation, and BaseLlmFlow's live send loop does the same. It is an
RxJava Scheduler rather than a task-runner function because every hop
site already takes one, and because a Scheduler can also express the
delayed scheduling RxJava's time-based operators need, which a plain
Executor cannot.

Agent-level settings keep precedence, so existing ParallelAgent.Builder
.scheduler() and LlmAgent.Builder.executor() callers see no change. The
runners that AgentTool nests still use the default scheduler; they can
inherit the parent's once google#1255 switches AgentTool to Runner.builder().
DABH added a commit to DABH/adk-java that referenced this pull request Oct 1, 2026
ADK hard-codes the RxJava IO scheduler wherever it leaves the caller's
thread: ParallelAgent subscribes each sub-agent on Schedulers.io(), tool
calls in ToolExecutionMode.PARALLEL_SUBSCRIBE are subscribed on it
unless the LlmAgent has an executor, and the live send loop is started
on it. An embedding application has no way to influence that fan-out,
for example to cap it, to run it on an existing worker pool, or to keep
every ADK task on a dispatcher it controls, which a durable-execution
host must. adk-go exposes this control point as platform.WithTaskRunner
/ platform.RunTasks (google/adk-go#1050).

Add the Java analog as an RxJava Scheduler carried on InvocationContext.
Runner.builder().scheduler(...) sets it once, every InvocationContext
the runner creates carries it (and toBuilder() copies it), and
InvocationContext.scheduler() returns it or Schedulers.io() when none is
configured, so the default behavior is unchanged. ParallelAgent uses the
invocation's scheduler unless its own builder set one (its default moves
from an eager Schedulers.io() to "inherit"), Functions resolves the
PARALLEL_SUBSCRIBE worker from the LlmAgent executor and then the
invocation, and BaseLlmFlow's live send loop does the same. It is an
RxJava Scheduler rather than a task-runner function because every hop
site already takes one, and because a Scheduler can also express the
delayed scheduling RxJava's time-based operators need, which a plain
Executor cannot.

Agent-level settings keep precedence, so existing
ParallelAgent.Builder.scheduler() and LlmAgent.Builder.executor()
callers see the same threading. Two small visible changes:
ParallelAgent.Builder.scheduler(null) now fails fast with a
NullPointerException instead of failing at run time, and
InvocationContext.equals()/hashCode() include the resolved scheduler.
The runners that AgentTool nests still use the default scheduler; they
can inherit the parent's once google#1255 switches AgentTool to
Runner.builder().
@DABH

DABH commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @sangkyoonnam. To save you the trouble, I've opened the Java analog of adk-go's platform.WithTaskRunner as #1587: an invocation-scoped RxJava Scheduler set once via Runner.builder().scheduler(...), carried on InvocationContext, and consulted by ParallelAgent, the PARALLEL_SUBSCRIBE tool fan-out, and the live request loop (agent-level scheduler/executor settings keep precedence; default stays Schedulers.io()). It is independent of this PR, so either can land first. Reviews welcome over there.

@DABH
DABH force-pushed the deterministic-clock-uuid-providers branch from ee6bf82 to 0bdb27f Compare October 1, 2026 08:40
DABH added a commit to DABH/adk-java that referenced this pull request Oct 1, 2026
ADK hard-codes the RxJava IO scheduler wherever it leaves the caller's
thread: ParallelAgent subscribes each sub-agent on Schedulers.io(), tool
calls in ToolExecutionMode.PARALLEL_SUBSCRIBE are subscribed on it
unless the LlmAgent has an executor, and the live send loop is started
on it. An embedding application has no way to influence that fan-out,
for example to cap it, to run it on an existing worker pool, or to keep
every ADK task on a dispatcher it controls, which a durable-execution
host must. adk-go exposes this control point as platform.WithTaskRunner
/ platform.RunTasks (google/adk-go#1050).

Add the Java analog as an RxJava Scheduler carried on InvocationContext.
Runner.builder().scheduler(...) sets it once, every InvocationContext
the runner creates carries it (and toBuilder() copies it), and
InvocationContext.scheduler() returns it or Schedulers.io() when none is
configured, so the default behavior is unchanged. ParallelAgent uses the
invocation's scheduler unless its own builder set one (its default moves
from an eager Schedulers.io() to "inherit"), Functions resolves the
PARALLEL_SUBSCRIBE worker from the LlmAgent executor and then the
invocation, and BaseLlmFlow's live send loop does the same. It is an
RxJava Scheduler rather than a task-runner function because every hop
site already takes one, and because a Scheduler can also express the
delayed scheduling RxJava's time-based operators need, which a plain
Executor cannot.

Agent-level settings keep precedence, so existing
ParallelAgent.Builder.scheduler() and LlmAgent.Builder.executor()
callers see the same threading. Two small visible changes:
ParallelAgent.Builder.scheduler(null) now fails fast with a
NullPointerException instead of failing at run time, and
InvocationContext.equals()/hashCode() include the resolved scheduler.
The runners that AgentTool nests still use the default scheduler; they
can inherit the parent's once google#1255 switches AgentTool to
Runner.builder().
@damianmomotgoogle damianmomotgoogle self-assigned this Oct 2, 2026
Comment thread core/src/main/java/com/google/adk/runner/Runner.java Outdated
Comment thread core/src/main/java/com/google/adk/runner/Runner.java Outdated
Comment thread core/src/main/java/com/google/adk/agents/InvocationContext.java Outdated
Comment thread core/src/main/java/com/google/adk/flows/llmflows/BaseLlmFlow.java
Comment thread core/src/main/java/com/google/adk/platform/TimeProvider.java Outdated
Comment thread core/src/test/java/com/google/adk/runner/RunnerTest.java Outdated
Comment thread core/src/main/java/com/google/adk/events/Event.java
Comment thread core/src/main/java/com/google/adk/flows/llmflows/Functions.java
Comment thread core/src/main/java/com/google/adk/platform/UuidProvider.java
@damianmomotgoogle damianmomotgoogle added waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. and removed needs review labels Oct 2, 2026
@DABH
DABH force-pushed the deterministic-clock-uuid-providers branch from 0bdb27f to d015321 Compare October 4, 2026 21:30
@DABH

DABH commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks @damianmomotgoogle for the thorough review - really appreciated, this made the PR noticeably better. All nine threads are addressed in 05a35f1 (still a single commit, rebased onto current main); I replied inline on each. The headline changes:

  • Builder semantics match main again: the default InMemorySessionService is created once per Runner.Builder and shared by every runner built from it, sessionService(null) fails in build() with the original IllegalStateException, and InvocationContext.Builder mints its default invocation id once per builder.
  • TimeProvider is gone in favor of java.time.InstantSource (Runner.builder().instantSource(...)); UuidProvider is the only new type.
  • Event.toBuilder() now always copies the timestamp (the drop-zero behavior only existed to serve a template hack in BaseLlmFlow, which is removed), so a clock fixed at Instant.EPOCH stays at 0 end to end.
  • Determinism tests now cover a tool-call turn, agent callbacks, and an output-schema agent with an EPOCH clock, and fail if any swept site falls back to the wall clock or UUID.randomUUID().
  • API narrowed (one new public constructor each on InMemorySessionService / LlmEventSummarizer, package-private populateClientFunctionCallId), and the Javadoc trimmed.

Full ./mvnw -Prelease clean package is green locally on JDK 17 (core: 2078 tests, 0 failures). The PR description is updated to match. Thanks again!

@DABH
DABH force-pushed the deterministic-clock-uuid-providers branch from d015321 to 05a35f1 Compare October 5, 2026 01:25
.appName(toolContext.agentName())
.plugins(plugins)
.memoryService(new InMemoryMemoryService())
.instantSource(parentContext.instantSource())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM: Passing a Mockito-mocked InvocationContext to AgentTool.runAsync now fails here. Because Mockito returns null from the new instantSource() and uuidProvider() accessors, Runner.Builder.instantSource(null) throws NullPointerException: instantSource cannot be null. This is the one regression I found on import: five existing internal test suites (30 test cases) fail, whereas they pass on main and with #1587, where Runner.Builder.scheduler(null) selects the default. Could instantSource(null) and uuidProvider(null) on Runner.Builder fall back to the defaults the same way? With that change, those suites and all core tests pass locally.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and sorry about the breakage — done. Runner.Builder.instantSource(null) and uuidProvider(null) now select the defaults (InstantSource.system() / UuidProvider.SYSTEM), the same rule as scheduler(null), and the InvocationContext.Builder setters follow suit. Added AgentToolTest.call_withMockedInvocationContext_fallsBackToDefaultProvidersAndScheduler, which runs an AgentTool from a Mockito-mocked InvocationContext with the provider and scheduler accessors unstubbed, plus null-selects-default tests on both builders. Also rebased onto main now that #1587 is in, so AgentTool passes the caller's scheduler and providers together. One heads-up in case another internal suite mocks InvocationContext and drives flow code directly: the swept event sites now call now() / newUuid() on the context, so such a mock needs those two stubbed (the repo's CodeExecutionTest does exactly that).

@DABH

DABH commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @damianmomotgoogle! Addressed in 387d79a: instantSource(null) / uuidProvider(null) now select the defaults on both builders (same rule as scheduler(null)), with a test that runs an AgentTool from a Mockito-mocked InvocationContext like the suites you hit. The branch is also rebased onto current main with #1587 merged, so the two seams sit side by side (AgentTool passes the caller's scheduler and providers together); still a single commit. Full ./mvnw -Prelease clean package is green locally on JDK 17 (core: 2105 tests, 0 failures); the description is updated. Thanks again!

@DABH
DABH force-pushed the deterministic-clock-uuid-providers branch from 05a35f1 to 387d79a Compare October 6, 2026 06:34
@sangkyoonnam

Copy link
Copy Markdown

I ran this PR at 387d79a, rebased onto main with #1587 already merged, inside a Temporal workflow and replayed the history. For every emitted and persisted event, the event id, timestamp, invocation id, and the function-call ids ADK mints came back identical, and so did the session id, with no nondeterminism error.

I built the runner with instantSource(() -> Instant.ofEpochMilli(Workflow.currentTimeMillis())), uuidProvider(() -> Workflow.randomUUID().toString()), and scheduler(Schedulers.trampoline()), plus an InMemorySessionService built with the same providers. The model is a BaseLlm that calls an activity, and the root is a ParallelAgent over two LlmAgents in PARALLEL_SUBSCRIBE mode. The trampoline runs the fan-out sequentially. A scheduler that wraps each task in Async.procedure also replayed identically, and both sub-agents' first model activities were scheduled in the same workflow task. That variant passed when the workflow waited with subscribe plus Workflow.await. Waiting with blockingGet() tripped the deadlock detector.

Controls: with the system clock and UUID.randomUUID, the replay diverges, and each provider moves only its own fields among the ones compared. With the scheduler left at Schedulers.io(), the run never completes, because the providers and the activity stub get called from RxCachedThreadScheduler threads and the workflow task keeps failing or timing out. On released 1.11.0 the same ParallelAgent setup never completes, because the activity stub gets called from RxCachedThreadScheduler threads. A single LlmAgent with PARALLEL tool execution, and the parallel tree with per-agent trampoline and direct executor settings, complete but replay with different ids and timestamps.

Setup: head 387d79a as pushed, no local merge. JDK 21, Temporal Java SDK 1.40.0. The model's first turn returns two function calls without ids, and each LlmAgent has before and after agent callbacks. I compared author, branch, ids, timestamps, invocation id, function-call and response names and ids, state delta, and text between the original run and a full-history WorkflowReplayer run on the test environment in one JVM. Worker restarts and mid-run recovery weren't tested.

Not covered: code execution (CodeExecutorContext), telemetry, streaming model adapters, runLive, AgentTool nested runs, resumability, compaction, and a model client that emits on its own thread. Here the model call returns on the workflow thread because it's an activity. The harness always passes non-null providers, so I checked the null case from the latest review separately: instantSource(null) and uuidProvider(null) on both Runner.Builder and InvocationContext.Builder build without error and use the system defaults, and non-null providers are still honored.

@damianmomotgoogle damianmomotgoogle added ready to pull and removed waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. labels Oct 6, 2026
Comment thread core/src/main/java/com/google/adk/summarizer/LlmEventSummarizer.java Outdated
Comment thread core/src/main/java/com/google/adk/flows/llmflows/Functions.java Outdated
Comment thread core/src/test/java/com/google/adk/runner/RunnerTest.java Outdated
Comment thread a2a/src/test/java/com/google/adk/a2a/agent/RemoteA2AAgentTest.java Outdated
Comment thread core/src/main/java/com/google/adk/runner/Runner.java Outdated
Comment thread a2a/src/main/java/com/google/adk/a2a/converters/EventConverter.java Outdated
@damianmomotgoogle damianmomotgoogle added waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. and removed ready to pull labels Oct 6, 2026
@damianmomotgoogle

Copy link
Copy Markdown
Contributor

Please also rebase on head revision

ADK generates timestamps and ids by calling Instant.now() and
UUID.randomUUID() directly, leaving callers no way to control them. This
blocks integrations that need to supply their own, such as
durable-execution hosts that replay an invocation deterministically.
adk-python (platform.time / platform.uuid) and adk-go (platform.Now /
platform.NewUUID) already expose an equivalent seam.

Give every invocation a java.time.InstantSource and a UuidProvider, a
new functional interface in the leaf com.google.adk.platform package
whose SYSTEM default is backed by UUID.randomUUID(). Both are configured
once on Runner.Builder (instantSource(), uuidProvider()) and threaded as
data through InvocationContext, which exposes them as instantSource(),
uuidProvider(), now(), and newUuid(). Threading them as data rather than
through an ambient ThreadLocal keeps them visible on whatever RxJava
worker thread builds an event. The Runner passes them to the default
InMemorySessionService it constructs, to the LlmEventSummarizer it
builds for event compaction, and to every InvocationContext it creates
for new, resumed, and live invocations. AgentTool's nested runner
inherits the parent invocation's providers.

Defaults keep their per-builder sharing semantics: Runner.Builder
creates its default session service once and reuses it for every
build(), an explicit sessionService(null) is still rejected at build()
rather than in the setter, and InvocationContext.Builder mints its
default invocation id once and shares it across repeated build() calls.

Every generated id and timestamp on the Runner -> agent -> flow ->
session-event path now derives from the invocation's providers: the
invocation id; user, model, tool, callback, code-execution, and
output-schema events; the resumability checkpoint and state-delta
events; replayed function-call re-ids; the client function-call ids the
flow mints (including the tool-confirmation request); compaction events;
remote A2A agent events and outbound message ids; the PlannerAgent
result event; and the session id and lastUpdateTime of the in-memory and
Firestore session services. Event.generateEventId() and the
Event.Builder timestamp default keep reading the system sources, so the
public Event API is unchanged; their Javadoc points custom agents,
tools, and plugins at InvocationContext.newUuid() and
InvocationContext.now().

Event.toBuilder() now copies the timestamp unconditionally. It used to
drop a zero timestamp so that BaseLlmFlow's pre-call event template,
which zeroed its own, would be re-stamped from the wall clock on
rebuild; with a clock fixed at the epoch that guard silently replaced
the timestamp of every rebuilt event. The template is now stamped from
the invocation's clock, and model-response events are timestamped when
the response arrives, as in adk-python. Code-execution and
tool-confirmation events, which had no id before, now carry one.
BaseLlmFlow no longer re-ids its per-step template after the model call:
nothing read it by then.

Runner.Builder.instantSource(null) and uuidProvider(null), and the
matching InvocationContext.Builder setters, select the system defaults,
like scheduler(null) does, so a nested run started from a mocked or
partially configured context still works. AgentTool passes the caller's
scheduler and providers to the runner it nests.

Out of scope: function-call ids a model adapter assigns itself while
aggregating a streamed response, the A2A server executor's protocol ids,
the Firestore create token, the wall-clock timestamp CodeExecutorContext
writes into session state, and telemetry and analytics timestamps and
ids. PlannerAgentTest is converted to JUnit 5 so the Jupiter-only
planners module actually runs it.
@DABH
DABH force-pushed the deterministic-clock-uuid-providers branch from 387d79a to caccc7c Compare October 7, 2026 03:23
@DABH

DABH commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @damianmomotgoogle! All seven threads are addressed in caccc7c and the branch is rebased onto current main (still a single commit); I replied inline on each. The only API-visible changes are that LlmEventSummarizer.DEFAULT_PROMPT_TEMPLATE is private again (the constructors take a @Nullable template, null = default) and Functions.populateClientFunctionCallId(Event, UuidProvider) is public like its siblings.

One related thing I noticed while rebasing: main's new unconvertible-part filter in ResponseConverter drops a remote event after remoteAgentEventBuilder has already drawn its id and timestamp, the same pattern as the EventConverter thread. Those draws depend only on the peer's response, so replay stays deterministic, and I left it alone to keep this round small; happy to convert the parts before building the event, either here or as a follow-up PR, whichever you prefer.

Full ./mvnw -Prelease clean package is green locally on JDK 17 (core: 2113 tests, 0 failures); the description is updated. Thanks again!

@damianmomotgoogle damianmomotgoogle added ready to pull and removed waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. labels Oct 7, 2026
@copybara-service
copybara-service Bot merged commit 6bf8259 into google:main Oct 7, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants