diff --git a/tokt/README.md b/tokt/README.md new file mode 100644 index 000000000..e1849d2a5 --- /dev/null +++ b/tokt/README.md @@ -0,0 +1,56 @@ +# tokt - ADK Java on the ADK Kotlin engine + +`tokt` ("to Kotlin") is a one-way interop that lets you run **existing ADK +Java** components - tools, toolsets, plugins, models, and services - on the +**ADK Kotlin** engine, without rewriting them. + +- `JavaAdkToKt` adapts individual ADK Java components into their ADK Kotlin + equivalents. Assemble the adapted pieces into a native Kotlin `LlmAgent` / + `App`. +- `KotlinAdkToJava.asJavaRunner` wraps the resulting Kotlin-engine `Runner` + back in the ADK Java `Runner` API, so existing Java call sites stay + unchanged. + +## Run existing ADK Java components on the Kotlin engine + +```java +import com.google.adk.kt.agents.LlmAgent; // ADK Kotlin-engine agent +import com.google.adk.kt.apps.App; +import com.google.adk.kt.runners.InMemoryRunner; +import com.google.adk.runner.Runner; // ADK Java Runner API +import com.google.adk.tokt.JavaAdkToKt; +import com.google.adk.tokt.KotlinAdkToJava; + +// Your existing ADK Java components: +BaseLlm model = new Gemini("gemini-flash-latest", client); +BaseTool weatherTool = FunctionTool.create(WeatherTools.class, "getWeather"); +BaseToolset mathToolset = new MathToolset(); +BasePlugin loggingPlugin = new LoggingPlugin(); + +// Adapt them and assemble a native Kotlin-engine agent + app: +LlmAgent agent = + LlmAgent.builder() + .name("assistant") + .model(JavaAdkToKt.asKtModel(model)) + .tools(JavaAdkToKt.asKtTools(List.of(weatherTool))) + .toolsets(JavaAdkToKt.asKtToolsets(List.of(mathToolset))) + .build(); +App app = + App.builder() + .appName("assistant") + .rootAgent(agent) + .plugins(JavaAdkToKt.asKtPlugins(List.of(loggingPlugin))) + .build(); + +// Drive the Kotlin-engine runner through the familiar ADK Java Runner API: +Runner runner = KotlinAdkToJava.asJavaRunner(InMemoryRunner.builder().app(app).build()); +List events = + runner + .runAsync( + "user", "session", message, RunConfig.builder().autoCreateSession(true).build()) + .toList() + .blockingGet(); +``` + +See the `JavaAdkToKt` KDoc for the full set of adapters (including session, +artifact, and memory services) and the documented interop limits. diff --git a/tokt/pom.xml b/tokt/pom.xml index 00eaa5f80..bbb1a639c 100644 --- a/tokt/pom.xml +++ b/tokt/pom.xml @@ -91,6 +91,11 @@ 4.13.2 test + + com.google.truth + truth + test + diff --git a/tokt/src/main/kotlin/com/google/adk/tokt/KotlinAdkToJava.kt b/tokt/src/main/kotlin/com/google/adk/tokt/KotlinAdkToJava.kt new file mode 100644 index 000000000..3574da1d1 --- /dev/null +++ b/tokt/src/main/kotlin/com/google/adk/tokt/KotlinAdkToJava.kt @@ -0,0 +1,34 @@ +/* + * 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.tokt + +import com.google.adk.kt.runners.Runner as KtRunner +import com.google.adk.runner.Runner as JavaRunner + +/** + * Reverse interop entry point: exposes an ADK Kotlin-engine [KtRunner] through the ADK Java + * [JavaRunner] surface, so it can be injected into code written against the Java runner. The + * returned runner is a real [JavaRunner] whose `runAsync` streams `Event`s backed by the Kotlin + * engine; live mode is not bridged. The forward direction (Java components onto the Kotlin engine) + * lives in [com.google.adk.tokt.JavaAdkToKt]. + */ +object KotlinAdkToJava { + + /** Exposes a Kotlin-engine [runner] as an ADK Java [JavaRunner]. */ + @JvmStatic + fun asJavaRunner(runner: KtRunner): JavaRunner = KtRunnerToJava(runner) +} diff --git a/tokt/src/main/kotlin/com/google/adk/tokt/KtRunnerToJava.kt b/tokt/src/main/kotlin/com/google/adk/tokt/KtRunnerToJava.kt new file mode 100644 index 000000000..c5c5afb6d --- /dev/null +++ b/tokt/src/main/kotlin/com/google/adk/tokt/KtRunnerToJava.kt @@ -0,0 +1,146 @@ +/* + * 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.tokt + +import com.google.adk.agents.LiveRequestQueue +import com.google.adk.agents.RunConfig as JavaRunConfig +import com.google.adk.artifacts.BaseArtifactService as JavaArtifactService +import com.google.adk.artifacts.InMemoryArtifactService as JavaInMemoryArtifactService +import com.google.adk.events.Event as JavaEvent +import com.google.adk.kt.runners.Runner as KtRunner +import com.google.adk.plugins.PluginManager as JavaPluginManager +import com.google.adk.runner.Runner as JavaRunner +import com.google.adk.sessions.Session as JavaSession +import com.google.adk.tokt.adapters.ktAgentAsJava +import com.google.adk.tokt.adapters.ktPluginManagerAsJava +import com.google.adk.tokt.codecs.ContentCodec +import com.google.adk.tokt.codecs.EventCodec +import com.google.adk.tokt.codecs.RunConfigCodec +import com.google.adk.tokt.codecs.stateDeltaFromJava +import com.google.adk.tokt.services.ktArtifactServiceAsJava +import com.google.adk.tokt.services.ktMemoryServiceAsJava +import com.google.adk.tokt.services.ktSessionServiceAsJava +import com.google.genai.types.Content as GenaiContent +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Flowable +import java.util.Optional +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.rx3.asFlowable + +/** + * A [JavaRunner] that delegates to a Kotlin-engine [KtRunner], so a Kotlin runner can be dropped + * into code written against the ADK Java [JavaRunner]: `runAsync` converts the request in and each + * event out, running on the Kotlin engine against its own services and plugins. It honors the Java + * `RunConfig.autoCreateSession` contract (erroring on a missing session unless it is set), but + * per-session request sequencing follows the Kotlin engine, not the Java runner; live mode is not + * bridged, so [runLive] returns a failed stream. [agent], [sessionService], [memoryService], + * [artifactService] and [pluginManager] read the Kotlin runner's own components back through the + * reverse adapters ([memoryService] / [artifactService] return `null` when absent; [pluginManager] + * is read-only and throws on registration). + */ +// Subclassing Runner via its @Deprecated 8-arg super-constructor is intended here. +@Suppress("DEPRECATION") +internal class KtRunnerToJava(private val ktRunner: KtRunner) : + JavaRunner( + // A Java view of the Kotlin runner's agent so agent() reads back; never run (see runAsync). + ktAgentAsJava(ktRunner.agent), + ktRunner.appName, + // Non-null for the Java Runner field; artifactService() returns this bridge when present and + // null when the Kotlin runner has none (the run then uses no artifact service). + ktRunner.artifactService?.let { ktArtifactServiceAsJava(it) } ?: JavaInMemoryArtifactService(), + ktSessionServiceAsJava(ktRunner.sessionService), + ktRunner.memoryService?.let { ktMemoryServiceAsJava(it) }, + emptyList(), + null, + null, + ) { + + override fun runAsync( + userId: String, + sessionId: String, + newMessage: GenaiContent, + runConfig: JavaRunConfig, + stateDelta: MutableMap?, + ): Flowable { + // Defer so a request-conversion or RunConfig rejection surfaces via onError, not at the call + // site - matching the base Runner and this class's own runLive. + return Flowable.defer { + val events = + ktRunner + .runAsync( + userId = userId, + sessionId = sessionId, + newMessage = ContentCodec.fromJava(newMessage), + // Translate the Java REMOVED sentinel so a caller-passed deletion deletes the key + // rather than storing it as a value (identity-matched by the engine). + stateDelta = stateDelta?.let { stateDeltaFromJava(it) }, + runConfig = RunConfigCodec.fromJava(runConfig), + ) + .map { EventCodec.toJava(it) } + .asFlowable() + // The Kotlin engine always creates a missing session; the Java Runner errors unless + // autoCreateSession is set, so honor that contract before delegating. + if (runConfig.autoCreateSession()) return@defer events + sessionService() + .getSession(appName(), userId, sessionId, Optional.empty()) + .map { true } + .defaultIfEmpty(false) + .flatMapPublisher { exists -> + if (exists) events + else + Flowable.error( + IllegalArgumentException("Session not found and autoCreateSession=false") + ) + } + } + } + + // Live mode is unsupported; surface it through the stream (like the base Runner) rather than + // throwing eagerly, so callers on the reactive path see it via onError. + override fun runLive( + session: JavaSession, + liveRequestQueue: LiveRequestQueue, + runConfig: JavaRunConfig, + ): Flowable = Flowable.error(liveUnsupported()) + + override fun runLive( + userId: String, + sessionId: String, + liveRequestQueue: LiveRequestQueue, + runConfig: JavaRunConfig, + ): Flowable = Flowable.error(liveUnsupported()) + + // A read-only Java view of the Kotlin runner's plugins (adapted Java plugins unwrapped); the + // engine's plugins are fixed at construction, so the returned manager throws on registration. + override fun pluginManager(): JavaPluginManager = ktPluginManagerAsJava(ktRunner.pluginManager) + + /** + * The bridged artifact service, or `null` when the Kotlin runner has none - unlike a plain + * [JavaRunner], whose accessor is non-null. Mirrors the Kotlin runner's nullable artifactService, + * as [memoryService] does for its own absence. + */ + override fun artifactService(): JavaArtifactService? = + if (ktRunner.artifactService == null) null else super.artifactService() + + // ktRunner.close() releases the Kotlin runner's plugins and toolsets; super.close() only reaches + // the Java agent view (which owns none) and the empty plugin manager, but is kept for symmetry. + override fun close(): Completable = + Completable.mergeArrayDelayError(super.close(), Completable.fromAction { ktRunner.close() }) + + private fun liveUnsupported() = + UnsupportedOperationException("Live mode is not supported when running on the Kotlin engine.") +} diff --git a/tokt/src/main/kotlin/com/google/adk/tokt/codecs/RunConfigCodec.kt b/tokt/src/main/kotlin/com/google/adk/tokt/codecs/RunConfigCodec.kt index b27c5c520..dfe1bf825 100644 --- a/tokt/src/main/kotlin/com/google/adk/tokt/codecs/RunConfigCodec.kt +++ b/tokt/src/main/kotlin/com/google/adk/tokt/codecs/RunConfigCodec.kt @@ -18,16 +18,14 @@ package com.google.adk.tokt.codecs import com.google.adk.agents.RunConfig as JavaRunConfig import com.google.adk.kt.agents.RunConfig as KtRunConfig +import com.google.adk.kt.agents.StreamingMode as KtStreamingMode /** - * Converts the Kotlin `kt.agents.RunConfig` to the ADK Java [JavaRunConfig] a bridged Java - * component reads through its invocation context. + * Converts between the Kotlin `kt.agents.RunConfig` and the ADK Java [JavaRunConfig]. * - * Only the three settings both frameworks model cross: streaming mode, the LLM call budget, and - * custom metadata. The Java-only settings (response modalities, speech and avatar config, audio - * transcription, tool execution mode, save-input-blobs, auto-create-session, and the - * group-function-responses override) keep their Java defaults, as the Kotlin engine has nothing to - * source them from. + * Only three settings cross both ways: streaming mode, the LLM call budget, and custom metadata. + * [toJava] leaves the Java-only settings at their defaults; [fromJava] instead rejects a Java + * setting the engine cannot honor rather than dropping it silently. */ internal object RunConfigCodec { @@ -42,4 +40,34 @@ internal object RunConfigCodec { .maxLlmCalls(config.maxLlmCalls) .customMetadata(config.customMetadata.orEmpty()) .build() + + /** Returns the Kotlin [KtRunConfig] view of the Java [config]. */ + @Suppress( + "deprecation" + ) // Reads the deprecated groupFunctionResponsesInHistoryOverride to reject it. + fun fromJava(config: JavaRunConfig): KtRunConfig { + val unsupported = buildList { + if (config.streamingMode() == JavaRunConfig.StreamingMode.BIDI) add("streamingMode=BIDI") + if (config.saveInputBlobsAsArtifacts()) add("saveInputBlobsAsArtifacts") + if (config.toolExecutionMode() != JavaRunConfig.ToolExecutionMode.NONE) + add("toolExecutionMode") + if (config.responseModalities().isNotEmpty()) add("responseModalities") + if (config.speechConfig() != null) add("speechConfig") + if (config.avatarConfig() != null) add("avatarConfig") + if (config.outputAudioTranscription() != null) add("outputAudioTranscription") + if (config.inputAudioTranscription() != null) add("inputAudioTranscription") + if (config.groupFunctionResponsesInHistoryOverride().isPresent) + add("groupFunctionResponsesInHistoryOverride") + } + require(unsupported.isEmpty()) { + "RunConfig settings not supported by the ADK Kotlin engine: $unsupported" + } + return KtRunConfig( + // Only NONE and SSE reach here; BIDI is rejected above. + streamingMode = + enumByNameOrNull(config.streamingMode().name) ?: KtStreamingMode.NONE, + maxLlmCalls = config.maxLlmCalls(), + customMetadata = config.customMetadata().takeIf { it.isNotEmpty() }, + ) + } } diff --git a/tokt/src/test/java/com/google/adk/tokt/KtRunnerToJavaShowcaseTest.java b/tokt/src/test/java/com/google/adk/tokt/KtRunnerToJavaShowcaseTest.java new file mode 100644 index 000000000..2794fbcd6 --- /dev/null +++ b/tokt/src/test/java/com/google/adk/tokt/KtRunnerToJavaShowcaseTest.java @@ -0,0 +1,113 @@ +/* + * 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.tokt; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assume.assumeTrue; + +import com.google.adk.agents.RunConfig; +import com.google.adk.events.Event; +import com.google.adk.kt.agents.LlmAgent; +import com.google.adk.kt.apps.App; +import com.google.adk.kt.runners.InMemoryRunner; +import com.google.adk.models.Gemini; +import com.google.adk.runner.Runner; +import com.google.adk.tools.FunctionTool; +import com.google.common.collect.ImmutableList; +import com.google.genai.Client; +import com.google.genai.types.Content; +import com.google.genai.types.FinishReason; +import com.google.genai.types.Part; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Java-only showcase of {@link KotlinAdkToJava#asJavaRunner}, driven through the ADK Java {@link + * Runner} API: it runs a native Kotlin agent - built from Java via the Kotlin builders - whose + * model and tool are adapted ADK Java components. API-key gated, so it skips (rather than fails) + * when {@code GOOGLE_API_KEY} is unset. + */ +@RunWith(JUnit4.class) +public final class KtRunnerToJavaShowcaseTest { + + @Test + public void kotlinAgentWithAdaptedJavaComponents_drivenThroughTheJavaRunnerApi() { + String apiKey = System.getenv("GOOGLE_API_KEY"); + assumeTrue( + "GOOGLE_API_KEY not set; skipping live showcase.", apiKey != null && !apiKey.isEmpty()); + + // Ordinary ADK Java components adapted onto the engine: a model and a FunctionTool. + Gemini javaModel = + new Gemini("gemini-flash-latest", Client.builder().apiKey(apiKey).vertexAI(false).build()); + LlmAgent agent = + LlmAgent.builder() + .name("assistant") + .model(JavaAdkToKt.asKtModel(javaModel)) + .instruction("Use the weather tool, then answer briefly.") + .tools( + ImmutableList.of( + JavaAdkToKt.asKtTool( + FunctionTool.create(LiveInteropTools.class, "getWeather")))) + .build(); + App app = App.builder().appName("showcase").rootAgent(agent).build(); + + // Expose the Kotlin runner as an ADK Java Runner; from here it is just the ADK Java API. + Runner runner = KotlinAdkToJava.asJavaRunner(InMemoryRunner.builder().app(app).build()); + + List events = + runner + .runAsync( + "user", + "session", + Content.builder() + .role("user") + .parts(Part.fromText("What's the weather in Paris?")) + .build(), + RunConfig.builder().maxLlmCalls(8).autoCreateSession(true).build()) + .toList() + .blockingGet(); + + assertThat(events).isNotEmpty(); + + // The adapted Java tool ran on the Kotlin engine and surfaced as a Java-shaped event. + ImmutableList toolNames = + events.stream() + .flatMap(e -> e.functionResponses().stream()) + .map(fr -> fr.name().orElse("")) + .collect(toImmutableList()); + assertThat(toolNames).contains("getWeather"); + + // finishReason (STOP) and non-zero token usage survived EventCodec's Java conversion. + Event finalModelEvent = + events.stream() + .filter(e -> e.finishReason().isPresent()) + .reduce((first, second) -> second) + .orElseThrow(() -> new AssertionError("no event carried a finish reason")); + assertThat(finalModelEvent.finishReason()).hasValue(new FinishReason(FinishReason.Known.STOP)); + assertThat( + events.stream() + .anyMatch( + e -> + e.usageMetadata() + .map(u -> u.totalTokenCount().orElse(0) > 0) + .orElse(false))) + .isTrue(); + } +} diff --git a/tokt/src/test/java/com/google/adk/tokt/LiveInteropTools.java b/tokt/src/test/java/com/google/adk/tokt/LiveInteropTools.java new file mode 100644 index 000000000..2e60a4a5e --- /dev/null +++ b/tokt/src/test/java/com/google/adk/tokt/LiveInteropTools.java @@ -0,0 +1,122 @@ +/* + * 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.tokt; + +import com.google.adk.agents.ReadonlyContext; +import com.google.adk.plugins.BasePlugin; +import com.google.adk.plugins.PluginManager; +import com.google.adk.tools.Annotations.Schema; +import com.google.adk.tools.BaseTool; +import com.google.adk.tools.BaseToolset; +import com.google.adk.tools.FunctionTool; +import com.google.adk.tools.ToolContext; +import com.google.common.collect.ImmutableMap; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Maybe; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Real ADK Java tools, a toolset, and plugins - the kind of components an ADK Java user writes - + * driven on the ADK Kotlin engine through the {@code tokt} forward-interop adapters. + * + *

Written in Java on purpose: the tools are {@link Schema}-annotated static methods, so {@code + * FunctionTool.create} exercises the real reflection-based declaration path that a hand-written + * {@code BaseTool} subclass would bypass. + */ +public final class LiveInteropTools { + + private LiveInteropTools() {} + + /** + * Set by {@link #inspectPlugins}: whether {@code state_probe_plugin} was visible through the + * bridged Java plugin manager on the invocation context (the Kt -> Java context plugin bridge). + */ + public static final AtomicBoolean stateProbePluginVisible = new AtomicBoolean(false); + + @Schema(description = "Lists the plugins active in the current invocation.") + public static ImmutableMap inspectPlugins( + @Schema(name = "toolContext") ToolContext toolContext) { + // The bridged invocation context exposes the Kotlin runner's plugins (adapted Java plugins + // unwrapped) as a Java PluginManager - the same path an AgentTool with includePlugins uses. + PluginManager pluginManager = (PluginManager) toolContext.invocationContext().pluginManager(); + boolean visible = pluginManager.getPlugin("state_probe_plugin").isPresent(); + stateProbePluginVisible.set(visible); + return ImmutableMap.of("stateProbePluginVisible", visible); + } + + @Schema(description = "Returns the current weather for a city.") + public static ImmutableMap getWeather( + @Schema(name = "city", description = "City to look up, e.g. 'Warsaw'.") String city) { + return ImmutableMap.of("city", city, "tempC", 21, "conditions", "sunny"); + } + + @Schema(description = "Adds two integers and returns their sum.") + public static ImmutableMap add( + @Schema(name = "a", description = "The first addend.") int a, + @Schema(name = "b", description = "The second addend.") int b) { + return ImmutableMap.of("sum", a + b); + } + + /** + * A Java toolset that reads its {@link ReadonlyContext} when provisioning tools, recording the + * agent name it saw so a test can assert the bridge handed it a non-null context. + */ + public static final class MathToolset implements BaseToolset { + private final List provisionedForAgents = new CopyOnWriteArrayList<>(); + + public List provisionedForAgents() { + return provisionedForAgents; + } + + @Override + public Flowable getTools(ReadonlyContext readonlyContext) { + if (readonlyContext != null) { + provisionedForAgents.add(readonlyContext.agentName()); + } + return Flowable.just(FunctionTool.create(LiveInteropTools.class, "add")); + } + + @Override + public void close() {} + } + + /** + * A Java plugin that mutates session state from its after-tool callback, including a + * write-then-remove that exercises the Java/Kotlin {@code State.REMOVED} sentinel translation. + */ + public static final class StateProbePlugin extends BasePlugin { + public StateProbePlugin() { + super("state_probe_plugin"); + } + + @Override + public Maybe> afterToolCallback( + BaseTool tool, + Map toolArgs, + ToolContext toolContext, + Map result) { + toolContext.state().put("last_tool", tool.name()); + toolContext.state().put("probe_temp", "temp"); + Object removed = toolContext.state().remove("probe_temp"); + toolContext.state().put("probe_removed_value", String.valueOf(removed)); + return Maybe.empty(); + } + } +} diff --git a/tokt/src/test/kotlin/com/google/adk/tokt/KtRunnerInteropTest.kt b/tokt/src/test/kotlin/com/google/adk/tokt/KtRunnerInteropTest.kt index cfc751d02..8c088000f 100644 --- a/tokt/src/test/kotlin/com/google/adk/tokt/KtRunnerInteropTest.kt +++ b/tokt/src/test/kotlin/com/google/adk/tokt/KtRunnerInteropTest.kt @@ -19,6 +19,7 @@ package com.google.adk.tokt import com.google.adk.agents.BaseAgent as JavaBaseAgent import com.google.adk.agents.CallbackContext as JavaCallbackContext import com.google.adk.agents.InvocationContext as JavaInvocationContext +import com.google.adk.agents.LiveRequestQueue import com.google.adk.agents.ReadonlyContext as JavaReadonlyContext import com.google.adk.agents.RunConfig as JavaRunConfig import com.google.adk.artifacts.InMemoryArtifactService as JavaInMemoryArtifactService @@ -35,6 +36,7 @@ import com.google.adk.kt.events.Event as KtEvent import com.google.adk.kt.events.EventActions as KtEventActions import com.google.adk.kt.models.LlmResponse as KtLlmResponse import com.google.adk.kt.runners.InMemoryRunner as KtInMemoryRunner +import com.google.adk.kt.runners.Runner as KtRunner import com.google.adk.kt.sessions.GetSessionConfig as KtGetSessionConfig import com.google.adk.kt.sessions.SessionKey as KtSessionKey import com.google.adk.kt.sessions.State as KtState @@ -74,6 +76,7 @@ import com.google.adk.models.LlmRequest as JavaLlmRequest import com.google.adk.models.LlmResponse as JavaLlmResponse import com.google.adk.plugins.BasePlugin as JavaBasePlugin import com.google.adk.plugins.PluginManager as JavaPluginManager +import com.google.adk.runner.Runner as JavaRunner import com.google.adk.sessions.BaseSessionService as JavaBaseSessionService import com.google.adk.sessions.GetSessionConfig as JavaGetSessionConfig import com.google.adk.sessions.InMemorySessionService as JavaInMemorySessionService @@ -86,6 +89,7 @@ import com.google.adk.tokt.codecs.FunctionDeclarationCodec import com.google.adk.tokt.codecs.GroundingMetadataCodec import com.google.adk.tokt.codecs.KtEventActionsToJavaView import com.google.adk.tokt.codecs.PartCodec +import com.google.adk.tokt.codecs.RunConfigCodec import com.google.adk.tokt.codecs.SchemaCodec import com.google.adk.tokt.codecs.SessionCodec import com.google.adk.tokt.codecs.agentStateFromJava @@ -136,7 +140,9 @@ import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue import kotlin.test.fail @@ -570,6 +576,16 @@ class KtRunnerInteropTest { throw UnsupportedOperationException() } + /** A Kotlin runner that records whether [close] was called, delegating everything else. */ + private class ClosingSpyKtRunner(private val delegate: KtRunner) : KtRunner by delegate { + var closed = false + + override fun close() { + closed = true + delegate.close() + } + } + @Test fun javaAdkToKt_convertsEntireCollections() { // Tools: order preserved, each Java tool wrapped as a Kotlin tool. @@ -677,6 +693,427 @@ class KtRunnerInteropTest { ) } + @Test + fun asJavaRunner_runsAKotlinRunner_throughTheJavaRunnerApi() { + // A Kotlin-engine runner, wrapped and then driven exactly like an ADK Java Runner. + val ktRunner = + KtInMemoryRunner( + agent = + KtLlmAgent( + name = "a", + model = + JavaAdkToKt.asKtModel( + SequentialJavaModel(listOf(modelText("done from the kotlin engine"))) + ), + ), + appName = "app", + ) + val javaRunner: JavaRunner = KotlinAdkToJava.asJavaRunner(ktRunner) + + val events: List = + javaRunner + .runAsync( + "u", + "s", + GenaiContent.builder().role("user").parts(GenaiPart.fromText("hi")).build(), + JavaRunConfig.builder().autoCreateSession(true).build(), + ) + .toList() + .blockingGet() + + // Events come back Java-shaped (genai Content), converted from the Kotlin engine's output. + assertEquals( + "done from the kotlin engine", + events.firstNotNullOfOrNull { + it.content().getOrNull()?.parts()?.getOrNull()?.firstOrNull()?.text()?.getOrNull() + }, + "the Java-facing runAsync should stream the Kotlin engine's events", + ) + assertEquals("app", javaRunner.appName(), "appName should report the Kotlin runner's") + + // The run persisted through the Kotlin runner's own session service, readable via the + // Java-facing accessor (the reverse session-service adapter). + val session = + javaRunner.sessionService().getSession("app", "u", "s", Optional.empty()).blockingGet() + assertTrue( + session != null && session.events().isNotEmpty(), + "the run should be persisted and visible through the Java sessionService()", + ) + } + + @Test + fun asJavaRunner_nonDefaultRunConfig_reachesTheKotlinEngine() { + // A Java RunConfig set through the Java API must map onto the Kotlin engine; the plugin reads + // the config the engine actually received. + val plugin = RunConfigCapturingJavaPlugin() + val ktRunner = + KtInMemoryRunner( + app = + KtApp( + appName = "app", + rootAgent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(SequentialJavaModel(listOf(modelText("done")))), + ), + plugins = listOf(JavaAdkToKt.asKtPlugin(plugin)), + ) + ) + val javaRunner = KotlinAdkToJava.asJavaRunner(ktRunner) + + val events = + javaRunner + .runAsync( + "u", + "s", + GenaiContent.builder().role("user").parts(GenaiPart.fromText("hi")).build(), + JavaRunConfig.builder() + .streamingMode(JavaRunConfig.StreamingMode.SSE) + .maxLlmCalls(7) + .autoCreateSession(true) + .build(), + ) + .toList() + .blockingGet() + + assertTrue(events.isNotEmpty(), "the run should have produced events") + val seen = assertNotNull(plugin.seen, "the plugin should have been handed the run config") + assertEquals(JavaRunConfig.StreamingMode.SSE, seen.streamingMode(), "streamingMode should map") + assertEquals(7, seen.maxLlmCalls(), "maxLlmCalls should map") + } + + @Test + fun asJavaRunner_runLiveErrors_andAgentReturnsAnInspectionView() { + val ktRunner = + KtInMemoryRunner( + agent = + KtLlmAgent(name = "a", model = JavaAdkToKt.asKtModel(SequentialJavaModel(emptyList()))), + appName = "app", + ) + val javaRunner = KotlinAdkToJava.asJavaRunner(ktRunner) + + // Live mode is not bridged; it must fail loudly through the stream (like the base Runner), not + // throw eagerly at the call site. + assertFailsWith { + javaRunner + .runLive("u", "s", LiveRequestQueue(), JavaRunConfig.builder().build()) + .toList() + .blockingGet() + } + // agent() returns an inspection-only view of the Kotlin agent - readable, but not runnable. + assertEquals("a", javaRunner.agent().name(), "agent() should expose the Kotlin agent's name") + + // The session-based runLive overload must fail the same way, through the stream. + val session = javaRunner.sessionService().createSession("app", "u", null, "s").blockingGet() + assertFailsWith { + javaRunner + .runLive(session, LiveRequestQueue(), JavaRunConfig.builder().build()) + .toList() + .blockingGet() + } + } + + @Test + fun asJavaRunner_pluginManager_listsUnwrappedJavaPlugins() { + // A Java plugin adapted onto the Kotlin runner is unwrapped back to the original instance, so a + // Java caller (e.g. an AgentTool with includePlugins) sees the real plugins. + val plugin = RunConfigCapturingJavaPlugin() + val ktRunner = + KtInMemoryRunner( + app = + KtApp( + appName = "app", + rootAgent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(SequentialJavaModel(emptyList())), + ), + plugins = listOf(JavaAdkToKt.asKtPlugin(plugin)), + ) + ) + val javaRunner = KotlinAdkToJava.asJavaRunner(ktRunner) + + assertSame( + plugin, + javaRunner.pluginManager().getPlugin("run_config_plugin").getOrNull(), + "the adapted Java plugin should be unwrapped to the original instance", + ) + } + + @Test + fun asJavaRunner_pluginManager_registrationThrows() { + // The Kotlin engine's plugins are fixed at construction, so a late Java-side registration must + // fail loudly rather than silently never run. + val ktRunner = + KtInMemoryRunner( + agent = + KtLlmAgent(name = "a", model = JavaAdkToKt.asKtModel(SequentialJavaModel(emptyList()))), + appName = "app", + ) + val javaRunner = KotlinAdkToJava.asJavaRunner(ktRunner) + + assertFailsWith { + javaRunner.pluginManager().registerPlugin(CountingJavaPlugin()) + } + } + + @Test + fun asJavaRunner_artifactService_present_isBridgedFaithfully() { + val ktRunner = + KtInMemoryRunner( + agent = + KtLlmAgent(name = "a", model = JavaAdkToKt.asKtModel(SequentialJavaModel(emptyList()))), + appName = "app", + ) + val javaRunner = KotlinAdkToJava.asJavaRunner(ktRunner) + + // The runner's in-memory artifact service is bridged back faithfully: a save is visible to a + // later load through the same service. + val artifacts = + assertNotNull(javaRunner.artifactService(), "artifactService() should be present") + val version = + artifacts.saveArtifact("app", "u", "s", "note.txt", GenaiPart.fromText("v1")).blockingGet() + assertEquals( + "v1", + artifacts.loadArtifact("app", "u", "s", "note.txt").blockingGet()?.text()?.orElse(""), + "a bridged artifact save (version $version) should be readable through the same service", + ) + } + + @Test + fun asJavaRunner_artifactService_absent_isNull() { + val ktRunner = + KtInMemoryRunner( + agent = + KtLlmAgent(name = "a", model = JavaAdkToKt.asKtModel(SequentialJavaModel(emptyList()))), + appName = "app", + artifactService = null, + ) + val javaRunner = KotlinAdkToJava.asJavaRunner(ktRunner) + + // No artifact service on the Kotlin runner: reported as null, mirroring its nullable field. + assertNull( + javaRunner.artifactService(), + "artifactService() should be null when the Kotlin runner has none", + ) + } + + @Test + fun asJavaRunner_memoryService_absent_isNull() { + val ktRunner = + KtInMemoryRunner( + agent = + KtLlmAgent(name = "a", model = JavaAdkToKt.asKtModel(SequentialJavaModel(emptyList()))), + appName = "app", + memoryService = null, + ) + val javaRunner = KotlinAdkToJava.asJavaRunner(ktRunner) + + assertNull( + javaRunner.memoryService(), + "memoryService() should be null when the Kotlin runner has none", + ) + } + + @Test + fun asJavaRunner_memoryService_present_isBridgedFaithfully() { + val ktRunner = + KtInMemoryRunner( + agent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(SequentialJavaModel(listOf(modelText("Paris")))), + ), + appName = "app", + ) + val javaRunner = KotlinAdkToJava.asJavaRunner(ktRunner) + + // Run once so the Kotlin runner persists a session, then index and search it back through the + // bridged Java memoryService() - present, and round-tripping faithfully. + javaRunner + .runAsync( + "u", + "s", + GenaiContent.builder().role("user").parts(GenaiPart.fromText("hi")).build(), + JavaRunConfig.builder().autoCreateSession(true).build(), + ) + .blockingSubscribe() + val memory = assertNotNull(javaRunner.memoryService(), "memoryService() should be present") + val session = + javaRunner.sessionService().getSession("app", "u", "s", Optional.empty()).blockingGet()!! + memory.addSessionToMemory(session).blockingAwait() + + assertTrue( + memory.searchMemory("app", "u", "Paris").blockingGet().memories().isNotEmpty(), + "a session indexed through the bridged memoryService() should be keyword-searchable", + ) + } + + @Test + fun asJavaRunner_close_closesTheKotlinRunner() { + val ktRunner = + ClosingSpyKtRunner( + KtInMemoryRunner( + agent = + KtLlmAgent(name = "a", model = JavaAdkToKt.asKtModel(SequentialJavaModel(emptyList()))), + appName = "app", + ) + ) + val javaRunner = KotlinAdkToJava.asJavaRunner(ktRunner) + + javaRunner.close().blockingAwait() + + assertTrue(ktRunner.closed, "close() should delegate to the Kotlin runner's close()") + } + + @Test + fun asJavaRunner_missingSession_withoutAutoCreate_errors() { + // The Java Runner errors on a missing session unless autoCreateSession is set; the wrapper must + // honor that rather than silently creating one the way the Kotlin engine does. + val javaRunner = + KotlinAdkToJava.asJavaRunner( + KtInMemoryRunner( + agent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(SequentialJavaModel(listOf(modelText("done")))), + ), + appName = "app", + ) + ) + + assertFailsWith { + javaRunner + .runAsync( + "u", + "missing", + GenaiContent.builder().role("user").parts(GenaiPart.fromText("hi")).build(), + JavaRunConfig.builder().build(), + ) + .toList() + .blockingGet() + } + } + + @Test + fun asJavaRunner_missingSession_withAutoCreate_runs() { + // With autoCreateSession set, a missing session is created and the run proceeds, matching the + // Java Runner and the Kotlin engine's own default behavior. + val javaRunner = + KotlinAdkToJava.asJavaRunner( + KtInMemoryRunner( + agent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(SequentialJavaModel(listOf(modelText("done")))), + ), + appName = "app", + ) + ) + + val events = + javaRunner + .runAsync( + "u", + "missing", + GenaiContent.builder().role("user").parts(GenaiPart.fromText("hi")).build(), + JavaRunConfig.builder().autoCreateSession(true).build(), + ) + .toList() + .blockingGet() + + assertTrue(events.isNotEmpty(), "the run should proceed once the session is auto-created") + } + + @Test + fun asJavaRunner_existingSession_withoutAutoCreate_runs() { + // The Java RunConfig default is autoCreateSession=false; an existing session must still run, + // covering the standard multi-turn path without auto-creation. + val javaRunner = + KotlinAdkToJava.asJavaRunner( + KtInMemoryRunner( + agent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(SequentialJavaModel(listOf(modelText("done")))), + ), + appName = "app", + ) + ) + javaRunner.sessionService().createSession("app", "u", null, "s").ignoreElement().blockingAwait() + + val events = + javaRunner + .runAsync( + "u", + "s", + GenaiContent.builder().role("user").parts(GenaiPart.fromText("hi")).build(), + JavaRunConfig.builder().build(), + ) + .toList() + .blockingGet() + + assertTrue(events.isNotEmpty(), "an existing session should run with the default RunConfig") + } + + @Test + fun asJavaRunner_runAsync_removedStateSentinel_deletesTheKey() { + val javaRunner = + KotlinAdkToJava.asJavaRunner( + KtInMemoryRunner( + agent = + KtLlmAgent( + name = "a", + model = + JavaAdkToKt.asKtModel( + SequentialJavaModel(listOf(modelText("one"), modelText("two"))) + ), + ), + appName = "app", + ) + ) + + // Turn 1: write a session-state key through the 5-arg runAsync stateDelta. + javaRunner + .runAsync( + "u", + "s", + GenaiContent.builder().role("user").parts(GenaiPart.fromText("hi")).build(), + JavaRunConfig.builder().autoCreateSession(true).build(), + mutableMapOf("k" to "v"), + ) + .ignoreElements() + .blockingAwait() + assertEquals( + "v", + javaRunner + .sessionService() + .getSession("app", "u", "s", Optional.empty()) + .blockingGet()!! + .state()["k"], + "turn 1 should persist the state key (so the removal below is not a vacuous pass)", + ) + // Turn 2: remove it with the Java REMOVED sentinel; the bridge must translate it to a deletion + // rather than store the Java singleton as a value the engine cannot match. + javaRunner + .runAsync( + "u", + "s", + GenaiContent.builder().role("user").parts(GenaiPart.fromText("bye")).build(), + JavaRunConfig.builder().build(), + mutableMapOf("k" to JavaState.REMOVED), + ) + .ignoreElements() + .blockingAwait() + + val session = + javaRunner.sessionService().getSession("app", "u", "s", Optional.empty()).blockingGet()!! + assertFalse( + session.state().containsKey("k"), + "a Java REMOVED sentinel in the runAsync stateDelta must delete the key, not store it", + ) + } + @Test fun ktRunner_javaPluginReadsRunConfig_seesTheRunsOwnSettings() = runBlocking { // The Java and Kotlin defaults coincide (NONE / 500 / empty), so only a non-default config @@ -2770,6 +3207,46 @@ class KtRunnerInteropTest { ) } + @Test + fun runConfigFromJava_unsupportedField_failsRatherThanDroppingIt() { + val java = JavaRunConfig.builder().maxLlmCalls(5).saveInputBlobsAsArtifacts(true).build() + + val failure = assertFailsWith { RunConfigCodec.fromJava(java) } + + assertTrue( + failure.message?.contains("saveInputBlobsAsArtifacts") == true, + "the failure should name the unsupported field, got ${failure.message}", + ) + } + + @Test + fun runConfigFromJava_bidiStreaming_failsRatherThanDowngradingToNone() { + val java = JavaRunConfig.builder().streamingMode(JavaRunConfig.StreamingMode.BIDI).build() + + val failure = assertFailsWith { RunConfigCodec.fromJava(java) } + + assertTrue( + failure.message?.contains("streamingMode=BIDI") == true, + "the failure should name BIDI, got ${failure.message}", + ) + } + + @Test + fun runConfigFromJava_supportedFields_convert() { + val java = + JavaRunConfig.builder() + .maxLlmCalls(5) + .streamingMode(JavaRunConfig.StreamingMode.SSE) + .customMetadata(mapOf("k" to "v")) + .build() + + val kt = RunConfigCodec.fromJava(java) + + assertEquals(5, kt.maxLlmCalls) + assertEquals(KtStreamingMode.SSE, kt.streamingMode) + assertEquals?>(mapOf("k" to "v"), kt.customMetadata) + } + private companion object { fun modelFunctionCall(name: String, args: Map): GenaiContent = GenaiContent.builder()