Repository navigation
feat: add injectable time and UUID providers for timestamps and ids - #1255
Conversation
|
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? |
|
@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. |
d92d033 to
742e700
Compare
|
Thanks @hemasekhar-p — addressed both. I rebased onto the latest |
|
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! |
|
Hi @hemasekhar-p , any further thoughts here on this PR? The equivalent Go PR got merged so just wanted to follow up. Thanks again! |
742e700 to
4e43c28
Compare
|
@DABH, thank you for the updates, To proceed with the review, could you please address merge conflicts? |
4e43c28 to
84b8c3e
Compare
|
@hemasekhar-p Thanks, this has been done - can you please review? Thanks again! |
|
@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. |
|
I need this for the same reason adk-python and adk-go added their |
84b8c3e to
3e36f14
Compare
3e36f14 to
ee6bf82
Compare
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().
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().
|
Thanks @sangkyoonnam. To save you the trouble, I've opened the Java analog of adk-go's |
ee6bf82 to
0bdb27f
Compare
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().
0bdb27f to
d015321
Compare
|
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
Full |
d015321 to
05a35f1
Compare
| .appName(toolContext.agentName()) | ||
| .plugins(plugins) | ||
| .memoryService(new InMemoryMemoryService()) | ||
| .instantSource(parentContext.instantSource()) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
|
Thanks @damianmomotgoogle! Addressed in 387d79a: |
05a35f1 to
387d79a
Compare
|
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 Controls: with the system clock and 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 Not covered: code execution ( |
|
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.
387d79a to
caccc7c
Compare
|
Thanks @damianmomotgoogle! All seven threads are addressed in caccc7c and the branch is rebased onto current One related thing I noticed while rebasing: Full |
Link to Issue or Description of Change
Problem:
ADK generates timestamps and IDs by calling
Instant.now()andUUID.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-pythonalready solves this withplatform.time/platform.uuid(google/adk-python#4200) andadk-gowithplatform.Now/platform.NewUUID(google/adk-go#964). Closely inspired by those, we propose adding the same functionality toadk-java.Solution:
Two seams, configured once on the
Runnerand carried on everyInvocationContextit creates:java.time.InstantSource(Java 17; already used in core).Runner.builder().instantSource(...)accepts anyClock,InstantSource.fixed(...), or lambda; the default isInstantSource.system().com.google.adk.platform.UuidProvider(String newUuid(), defaultSYSTEMbacked byUUID.randomUUID()), configured withRunner.builder().uuidProvider(...). There is no JDK equivalent, so this is the only new type.nullto either setter (onRunner.BuilderorInvocationContext.Builder) selects the default, the same rule asscheduler(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 thenow()/newUuid()conveniences) rather than stored in an ambientThreadLocal. ADK's core flow hops threads via RxJava (e.g.BaseLlmFlowobserves onSchedulers.io()/ the agent executor), and events are built downstream on those worker threads. AThreadLocalwould silently fall back to the system providers after a thread hop, failing open with no error. Carrying the providers on theInvocationContext(which is already captured in the RxJava lambdas and passed down the chain) makes them visible on whatever thread builds an event. This mirrorsadk-go, which diverged fromadk-python's contextvar mechanism for the same concurrency reason.The
Runnerinjects the providers into the defaultInMemorySessionServiceit constructs, into theLlmEventSummarizerit builds for event compaction, and into everyInvocationContextit creates for new, resumed, and live invocations.AgentToolbuilds the runner it nests withRunner.builder()carrying the parent invocation's scheduler (from #1587) and providers (the caller'sRunConfighandling from main is unchanged). Two builder behaviors are preserved as onmain: the default session service is created once perRunner.Builder(now lazily, on the firstbuild(), so it uses the configured providers) and shared by every runner built from it, andInvocationContext.Buildermints its default invocation id once per builder, so repeatedbuild()calls share it.Runner.Builder.sessionService(null)still fails inbuild()withIllegalStateException, 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), plusnow()/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 theadk_request_confirmationcall id).LlmEventSummarizer— compaction events (constructor injection from theRunner).InMemorySessionServiceandcontrib/firestore-session-service— auto-generated session id andlastUpdateTime(one additional, full-argument constructor each; the existing constructors keep the system defaults).LlmEventSummarizerlikewise gains one full-argument constructor; passing a null prompt template selects the default one.a2a—RemoteA2AAgentaggregated events and outbound A2A message ids,ResponseConverterevents;EventConverterno longer mints a throwaway event (and id) when rephrasing another agent's output as request parts.contrib/planners—PlannerAgentresult event.Event.generateEventId()and theEvent.Builder.build()timestamp default now delegate toUuidProvider.SYSTEM/InstantSource.system(), so ADK's flow and session code has a single source for generated ids and timestamps while theEventAPI stays unchanged; their Javadoc points custom agents, tools, and plugins atInvocationContext.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 inBaseLlmFlow(now removed) and silently replaced a clock fixed atInstant.EPOCHwith the wall clock on every rebuild.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 inPersistBarrier.new FirestoreSessionService(firestore)now rejects a null client eagerly (it delegates to the full constructor'srequireNonNull) instead of failing on first use.BaseLlmFlowno 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
agentanalyticsplugin (TraceManager,PluginState,Parser,BigQueryAgentAnalyticsPlugin), the elapsed-time telemetry intelemetry/Instrumentation, the A2A server-sideAgentExecutorprotocol 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 (Geminiin streaming mode,LangChain4j; model adapters have noInvocationContext), the deploy CLI's default deployment name, the Kotlin interop views (they already carry explicit ids from the Kotlin engine), the wall-clocktimestampthatCodeExecutorContextwrites into session state during code execution (sweeping it needs anInstantSourceon its public constructor; happy to do that as a follow-up), and the id and timestampResponseConverterdraws for a remote event beforemain'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 ofadk-go'splatform.WithTaskRunner(google/adk-go#1050), landed separately in #1587, and the two seams now sit side by side onInvocationContextandRunner.Builder.Testing Plan
Unit Tests:
New/updated tests:
platform/UuidProviderTest—SYSTEMdefault + custom-provider behavior.InvocationContextTest— providers default to the system sources,nullselects them, they are carried by the builder /toBuilder(), drivenow()/newUuid(), and the default invocation id is minted exactly once per builder (repeatedbuild()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) andOutputSchema.createFinalModelResponseEventmint their event id and timestamp from the invocation's providers (these ids are re-assigned byBaseLlmFlowin the async step path, so they are pinned at the unit level).InMemorySessionServiceTest— injected providers produce a deterministic session id andlastUpdateTime, combined withDuplicateSessionIdBehavior.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 atInstant.EPOCHand 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 theBaseAgentcallback sites or theFunctions/OutputSchematimestamps fails them; the ids those two methods draw are re-assigned byBaseLlmFlowin the async step path and are pinned by the directFunctionsTest/OutputSchemaTestcases); the default session service is shared by runners built from one builder;sessionService(null)fails inbuild(),sessionService(null).sessionService(s)builds;instantSource(null)/uuidProvider(null)select the system defaults; the before-run plugin event andrunLiveevents 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-mockedInvocationContext(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 mockednewUuid()/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,toktand the remaining modules all pass with 0 failures.git diff --exit-codeafter 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
Additional context
Motivated by maintaining feature parity with
adk-pythonandadk-go. Rebased onto the currentmain(9f5d5ce, which includes #1587; single commit); the sweep was extended to the call sitesmaingained 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).