diff --git a/core/src/main/java/com/google/adk/sessions/Session.java b/core/src/main/java/com/google/adk/sessions/Session.java index 24251619d..cd0e3b114 100644 --- a/core/src/main/java/com/google/adk/sessions/Session.java +++ b/core/src/main/java/com/google/adk/sessions/Session.java @@ -127,6 +127,27 @@ public Builder events(List events) { return this; } + /** + * Backs {@link Session#events()} with the given list directly, without copying, so the + * session reflects a caller-owned live or converting view (e.g. an engine-interop adapter + * exposing another framework's session, whose events grow as that engine appends). + * + *

For framework and interop adapters; ordinary code should use {@link #events(List)}, which + * defensively copies. The caller keeps ownership, so the list also keeps its own semantics: it + * is responsible for thread-safety, a read-only view throws on {@code add}, and {@code + * synchronized (session.events())} no longer excludes the owner's appends. + * + * @deprecated Not deprecated in the ordinary sense - nothing replaces it and it is not going + * away. Marked so that application code is warned off a seam that exists for ADK's own + * framework and interop adapters, which keep using it. Use {@link #events(List)} instead. + */ + @Deprecated + @CanIgnoreReturnValue + public Builder eventsView(List events) { + this.events = events; + return this; + } + @CanIgnoreReturnValue public Builder lastUpdateTime(Instant lastUpdateTime) { this.lastUpdateTime = lastUpdateTime; diff --git a/pom.xml b/pom.xml index 31fe8a93f..3162a0f61 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,7 @@ tutorials/city-time-weather tutorials/live-audio-single-agent a2a + tokt @@ -67,6 +68,10 @@ 1.4.5 1.0.0 3.1.12 + + 2.1.20 + 1.11.0 + 0.7.0 3.7.0 2.35.1 3.27.7 diff --git a/tokt/pom.xml b/tokt/pom.xml new file mode 100644 index 000000000..365b404e8 --- /dev/null +++ b/tokt/pom.xml @@ -0,0 +1,142 @@ + + + + 4.0.0 + + + com.google.adk + google-adk-parent + 1.7.2-SNAPSHOT + + + google-adk-tokt + Agent Development Kit - Kotlin engine interop + One-way interop that adapts ADK Java tools, toolsets, plugins, services, and models so they can run on the ADK Kotlin engine. The name is short for "to Kotlin", matching the JavaAdkToKt entry point. + + + + + com.google.adk + google-adk + ${project.version} + + + + com.google.adk + google-adk-kotlin-core-jvm + ${adk-kotlin.version} + + + com.google.genai + google-genai + + + io.reactivex.rxjava3 + rxjava + + + com.google.guava + guava + 33.0.0-jre + + + org.jspecify + jspecify + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + org.jetbrains.kotlinx + kotlinx-coroutines-core-jvm + ${kotlinx-coroutines.version} + + + org.jetbrains.kotlinx + kotlinx-coroutines-rx3 + ${kotlinx-coroutines.version} + + + org.jetbrains.kotlinx + kotlinx-coroutines-reactive + ${kotlinx-coroutines.version} + + + + + org.jetbrains.kotlin + kotlin-test-junit + ${kotlin.version} + test + + + junit + junit + 4.13.2 + test + + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + ${java.version} + + -opt-in=kotlin.time.ExperimentalTime + + + + + compile + compile + + compile + + + + ${project.basedir}/src/main/java + + + + + test-compile + test-compile + + test-compile + + + + ${project.basedir}/src/test/java + + + + + + + maven-surefire-plugin + + + + diff --git a/tokt/src/main/java/com/google/adk/tokt/InteropDispatcher.kt b/tokt/src/main/java/com/google/adk/tokt/InteropDispatcher.kt new file mode 100644 index 000000000..bf738509d --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/InteropDispatcher.kt @@ -0,0 +1,29 @@ +/* + * 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 kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +/** + * The dispatcher every crossing in this module hops to. + * + * ADK Java's SPI is RxJava, which is synchronous unless the implementation says otherwise, so a + * user-authored Java tool, plugin or service may block. Running it on the engine's dispatcher would + * stall the coroutine driving the agent loop, so each adapter moves the call here. + */ +internal val InteropDispatcher: CoroutineDispatcher = Dispatchers.IO diff --git a/tokt/src/main/java/com/google/adk/tokt/JavaAdkToKt.kt b/tokt/src/main/java/com/google/adk/tokt/JavaAdkToKt.kt new file mode 100644 index 000000000..3133d478e --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/JavaAdkToKt.kt @@ -0,0 +1,114 @@ +/* + * 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.artifacts.BaseArtifactService as JavaArtifactService +import com.google.adk.kt.artifacts.ArtifactService as KtArtifactService +import com.google.adk.kt.memory.MemoryService as KtMemoryService +import com.google.adk.kt.models.Model as KtModel +import com.google.adk.kt.plugins.Plugin as KtPlugin +import com.google.adk.kt.sessions.SessionService as KtSessionService +import com.google.adk.kt.tools.BaseTool as KtBaseTool +import com.google.adk.kt.tools.Toolset as KtToolset +import com.google.adk.memory.BaseMemoryService as JavaMemoryService +import com.google.adk.models.BaseLlm as JavaBaseLlm +import com.google.adk.plugins.Plugin as JavaPlugin +import com.google.adk.sessions.BaseSessionService as JavaSessionService +import com.google.adk.tokt.adapters.JavaModelToKt +import com.google.adk.tokt.adapters.JavaPluginToKt +import com.google.adk.tokt.adapters.JavaToolToKt +import com.google.adk.tokt.adapters.JavaToolsetToKt +import com.google.adk.tokt.services.javaArtifactServiceAsKt +import com.google.adk.tokt.services.javaMemoryServiceAsKt +import com.google.adk.tokt.services.javaSessionServiceAsKt +import com.google.adk.tools.BaseTool as JavaBaseTool +import com.google.adk.tools.BaseToolset as JavaBaseToolset + +/** + * Forward interop entry point: adapts ADK Java tools, toolsets, plugins, services, and models so + * they can run on the ADK Kotlin engine. Wrap the adapted pieces in a Kotlin `LlmAgent`; this does + * not convert a whole Java agent. + * + * An adapted component behaves as it does on ADK Java. It sees the session as it currently stands, + * including events and state written earlier in the same turn, and its state, artifact and + * control-flow writes reach the engine. Blocking work is fine: calls are dispatched off the thread + * driving the agent. + * + * Two things to know before relying on them: + * - Setting `branch` on a bridged context throws. The branch is the engine's to set. + * - Behind an adapted Java session service ([asKtSessionService]), a resumable workflow restarts + * instead of resuming, because ADK Java has nowhere to store the engine's resumption state. Keep + * the Kotlin session service if you rely on resumability. + */ +object JavaAdkToKt { + + /** Adapts an ADK Java tool. */ + @JvmStatic fun asKtTool(javaTool: JavaBaseTool): KtBaseTool = JavaToolToKt(javaTool) + + /** + * Adapts a whole collection of ADK Java tools (e.g. an `LlmAgent`'s `tools`). Kept alongside + * [asKtTool] for Java callers, who would otherwise write `stream().map(...).toList()`. + */ + @JvmStatic + fun asKtTools(javaTools: List): List = javaTools.map { asKtTool(it) } + + /** Adapts an ADK Java toolset. */ + @JvmStatic fun asKtToolset(javaToolset: JavaBaseToolset): KtToolset = JavaToolsetToKt(javaToolset) + + /** Adapts a whole collection of ADK Java toolsets. */ + @JvmStatic + fun asKtToolsets(javaToolsets: List): List = javaToolsets.map { + asKtToolset(it) + } + + /** Adapts an ADK Java plugin. */ + @JvmStatic fun asKtPlugin(javaPlugin: JavaPlugin): KtPlugin = JavaPluginToKt(javaPlugin) + + /** Adapts a whole collection of ADK Java plugins (e.g. a `Runner`'s `plugins`). */ + @JvmStatic + fun asKtPlugins(javaPlugins: List): List = javaPlugins.map { + asKtPlugin(it) + } + + /** Adapts an ADK Java model so the Kotlin engine can call it. */ + @JvmStatic fun asKtModel(javaLlm: JavaBaseLlm): KtModel = JavaModelToKt(javaLlm) + + /** + * Adapts an ADK Java session service for the Kotlin engine. Passing a service that is itself an + * adapted Kotlin one returns the original rather than stacking a second adapter. Note the + * `agentState` / `rewindBeforeInvocationId` loss described in [JavaAdkToKt]. + */ + @JvmStatic + fun asKtSessionService(service: JavaSessionService): KtSessionService = + javaSessionServiceAsKt(service) + + /** + * Adapts an ADK Java artifact service for the Kotlin engine, unwrapping a round-tripped Kotlin + * one rather than stacking adapters. An empty or unmapped artifact part is rejected outright. + */ + @JvmStatic + fun asKtArtifactService(service: JavaArtifactService): KtArtifactService = + javaArtifactServiceAsKt(service) + + /** + * Adapts an ADK Java memory service for the Kotlin engine, unwrapping a round-tripped Kotlin one + * rather than stacking adapters. + */ + @JvmStatic + fun asKtMemoryService(service: JavaMemoryService): KtMemoryService = + javaMemoryServiceAsKt(service) +} diff --git a/tokt/src/main/java/com/google/adk/tokt/adapters/JavaModelToKt.kt b/tokt/src/main/java/com/google/adk/tokt/adapters/JavaModelToKt.kt new file mode 100644 index 000000000..497eb7923 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/adapters/JavaModelToKt.kt @@ -0,0 +1,58 @@ +/* + * 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.adapters + +import com.google.adk.kt.models.LlmRequest +import com.google.adk.kt.models.LlmResponse +import com.google.adk.kt.models.Model +import com.google.adk.models.BaseLlm as JavaBaseLlm +import com.google.adk.tokt.InteropDispatcher +import com.google.adk.tokt.codecs.LlmRequestCodec +import com.google.adk.tokt.codecs.LlmResponseCodec +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.reactive.asFlow + +/** + * Java -> Kotlin adapter: presents a user's ADK Java [JavaBaseLlm] as a Kotlin [Model] so the ADK + * Kotlin agent loop can call it. The model seam for routing ADK Java's `LlmAgent` onto the Kotlin + * engine. + * + * The Kotlin `LlmRequest` is converted to a Java `LlmRequest` ([LlmRequestCodec]), the Java model's + * RxJava `Flowable` is consumed as a coroutine [Flow] (via + * kotlinx-coroutines-reactive) and each response is converted back ([LlmResponseCodec]). + */ +internal class JavaModelToKt(private val javaLlm: JavaBaseLlm) : Model { + + override val name: String = javaLlm.model() + + // Deferred into `flow {}` and dispatched on IO: the Java model's request build + generation run + // off the engine dispatcher (RxJava is synchronous by default), and a synchronous throw is routed + // through the Flow's error channel rather than escaping at collection time. + override fun generateContent(request: LlmRequest, stream: Boolean): Flow = + flow { + emitAll( + javaLlm.generateContent(LlmRequestCodec.toJava(request), stream).asFlow().map { + LlmResponseCodec.fromJava(it) + } + ) + } + .flowOn(InteropDispatcher) +} diff --git a/tokt/src/main/java/com/google/adk/tokt/adapters/JavaPluginToKt.kt b/tokt/src/main/java/com/google/adk/tokt/adapters/JavaPluginToKt.kt new file mode 100644 index 000000000..7c68a80b2 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/adapters/JavaPluginToKt.kt @@ -0,0 +1,246 @@ +/* + * 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.adapters + +import com.google.adk.kt.agents.CallbackContext as KtCallbackContext +import com.google.adk.kt.agents.InvocationContext as KtInvocationContext +import com.google.adk.kt.callbacks.CallbackChoice +import com.google.adk.kt.events.Event as KtEvent +import com.google.adk.kt.events.EventActions as KtEventActions +import com.google.adk.kt.models.LlmRequest as KtLlmRequest +import com.google.adk.kt.models.LlmResponse as KtLlmResponse +import com.google.adk.kt.plugins.Plugin as KtPlugin +import com.google.adk.kt.tools.BaseTool as KtBaseTool +import com.google.adk.kt.tools.ToolContext as KtToolContext +import com.google.adk.kt.types.Content as KtContent +import com.google.adk.plugins.Plugin as JavaPlugin +import com.google.adk.tokt.InteropDispatcher +import com.google.adk.tokt.codecs.ContentCodec +import com.google.adk.tokt.codecs.EventCodec +import com.google.adk.tokt.codecs.LlmRequestCodec +import com.google.adk.tokt.codecs.LlmResponseCodec +import com.google.adk.tokt.codecs.reconcileRemovedSentinels +import com.google.adk.tokt.context.KtInvocationContextToJavaView +import com.google.adk.tokt.context.javaAgentView +import com.google.adk.tokt.context.ktCallbackContextToJava +import com.google.adk.tokt.context.ktToolContextToJava +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Maybe +import kotlinx.coroutines.rx3.await +import kotlinx.coroutines.rx3.awaitSingleOrNull +import kotlinx.coroutines.withContext + +/** + * Java -> Kotlin adapter: exposes an ADK Java [JavaPlugin] as a Kotlin [KtPlugin] so a Java app's + * plugins run on the Kotlin runner. Each callback converts the Kotlin context to its Java facade + * ([KtInvocationContextToJavaView] / [ktCallbackContextToJava] / [ktToolContextToJava]), invokes + * Java plugin, and awaits its RxJava result as a coroutine. + * + * The Java agent each callback sees is the running engine agent wrapped via [javaAgentView], so no + * root agent has to be supplied and the same instance is reused for the whole invocation. + * Tool-level callbacks apply only to Kt-backed Java tools ([JavaToolToKt]); native Kotlin tools + * pass through unchanged. + */ +internal class JavaPluginToKt(private val plugin: JavaPlugin) : KtPlugin { + + override val name: String + get() = plugin.name + + /** + * Runs a Java plugin callback off the engine dispatcher. Plugin callbacks may do blocking I/O + * (logging, metrics, network); RxJava is synchronous by default, so running them on the coroutine + * that drives the agent loop could stall it. + */ + private suspend fun onIo(source: () -> Maybe): T? = + withContext(InteropDispatcher) { source().awaitSingleOrNull() } + + /** As [onIo], for the two callbacks that report completion rather than a value. */ + private suspend fun completeOnIo(source: () -> Completable) { + withContext(InteropDispatcher) { source().await() } + } + + /** + * Maps a Java removal sentinel, written through the live callback state, to the Kotlin sentinel + * so the engine recognizes the deletion. + */ + private fun reconcileState(context: KtCallbackContext) { + reconcileRemovedSentinels(context.eventActions.stateDelta) + } + + /** + * As [reconcileState], for the live tool-context actions a tool-callback plugin writes through. + */ + private fun reconcileToolState(context: KtToolContext) { + reconcileRemovedSentinels(context.actions.stateDelta) + } + + // Run-level callbacks. + + override suspend fun onUserMessage( + invocationContext: KtInvocationContext, + userMessage: KtContent, + ): KtContent { + val javaContext = KtInvocationContextToJavaView(invocationContext) + val replacement = onIo { + plugin.onUserMessageCallback(javaContext, ContentCodec.toJava(userMessage)) + } + return replacement?.let { ContentCodec.fromJava(it) } ?: userMessage + } + + override suspend fun beforeRun( + invocationContext: KtInvocationContext + ): CallbackChoice { + val javaContext = KtInvocationContextToJavaView(invocationContext) + val halt = onIo { plugin.beforeRunCallback(javaContext) } + return if (halt != null) CallbackChoice.Break(ContentCodec.fromJava(halt)) + else CallbackChoice.Continue(Unit) + } + + override suspend fun onEvent(invocationContext: KtInvocationContext, event: KtEvent): KtEvent { + val javaContext = KtInvocationContextToJavaView(invocationContext) + val replacement = onIo { plugin.onEventCallback(javaContext, EventCodec.toJava(event)) } + return replacement?.let { EventCodec.fromJava(it) } ?: event + } + + override suspend fun afterRun(invocationContext: KtInvocationContext) { + val javaContext = KtInvocationContextToJavaView(invocationContext) + completeOnIo { plugin.afterRunCallback(javaContext) } + } + + // Agent-level callbacks. + + override suspend fun beforeAgent( + context: KtCallbackContext + ): CallbackChoice { + val javaAgent = javaAgentView(context) + val override = onIo { + plugin.beforeAgentCallback(javaAgent, ktCallbackContextToJava(context, javaAgent)) + } + reconcileState(context) + return if (override != null) CallbackChoice.Break(ContentCodec.fromJava(override)) + else CallbackChoice.Continue(KtEventActions()) + } + + override suspend fun afterAgent(context: KtCallbackContext): CallbackChoice { + val javaAgent = javaAgentView(context) + val override = onIo { + plugin.afterAgentCallback(javaAgent, ktCallbackContextToJava(context, javaAgent)) + } + reconcileState(context) + return if (override != null) CallbackChoice.Break(ContentCodec.fromJava(override)) + else CallbackChoice.Continue(Unit) + } + + // Model-level callbacks. + + override suspend fun beforeModel( + context: KtCallbackContext, + request: KtLlmRequest, + ): CallbackChoice { + val builder = LlmRequestCodec.toJava(request).toBuilder() + val override = onIo { + plugin.beforeModelCallback(ktCallbackContextToJava(context, javaAgentView(context)), builder) + } + reconcileState(context) + return if (override != null) CallbackChoice.Break(LlmResponseCodec.fromJava(override)) + // Re-apply onto the original request to preserve Kotlin-only fields (toolsDict, cache config); + // LlmRequestCodec.fromJava would build a fresh request and drop them, breaking agent transfer. + else CallbackChoice.Continue(reapplyJavaRequest(request, builder.build())) + } + + override suspend fun afterModel( + context: KtCallbackContext, + response: KtLlmResponse, + ): KtLlmResponse { + val override = onIo { + plugin.afterModelCallback( + ktCallbackContextToJava(context, javaAgentView(context)), + LlmResponseCodec.toJava(response), + ) + } + reconcileState(context) + return if (override != null) LlmResponseCodec.fromJava(override) else response + } + + override suspend fun onModelError( + context: KtCallbackContext, + request: KtLlmRequest, + error: Throwable, + ): CallbackChoice { + val builder = LlmRequestCodec.toJava(request).toBuilder() + val fallback = onIo { + plugin.onModelErrorCallback( + ktCallbackContextToJava(context, javaAgentView(context)), + builder, + error, + ) + } + reconcileState(context) + return if (fallback != null) CallbackChoice.Break(LlmResponseCodec.fromJava(fallback)) + else CallbackChoice.Continue(Unit) + } + + // Tool-level callbacks (only Kt-backed Java tools). + + override suspend fun beforeTool( + context: KtToolContext, + tool: KtBaseTool, + args: Map, + ): CallbackChoice, Map> { + val javaTool = (tool as? JavaToolToKt)?.javaTool ?: return CallbackChoice.Continue(args) + val mutableArgs = args.toMutableMap() + val override = onIo { + plugin.beforeToolCallback(javaTool, mutableArgs, ktToolContextToJava(context)) + } + reconcileToolState(context) + return if (override != null) CallbackChoice.Break(override) + else CallbackChoice.Continue(mutableArgs) + } + + override suspend fun afterTool( + context: KtToolContext, + tool: KtBaseTool, + args: Map, + result: Map, + ): Map { + val javaTool = (tool as? JavaToolToKt)?.javaTool ?: return result + val override = onIo { + plugin.afterToolCallback(javaTool, args, ktToolContextToJava(context), result) + } + reconcileToolState(context) + return override ?: result + } + + override suspend fun onToolError( + context: KtToolContext, + tool: KtBaseTool, + args: Map, + error: Throwable, + ): CallbackChoice> { + val javaTool = (tool as? JavaToolToKt)?.javaTool ?: return CallbackChoice.Continue(Unit) + val fallback = onIo { + plugin.onToolErrorCallback(javaTool, args, ktToolContextToJava(context), error) + } + reconcileToolState(context) + return if (fallback != null) CallbackChoice.Break(fallback) else CallbackChoice.Continue(Unit) + } + + override fun close() { + // Plugin.close() is non-suspend (runner shutdown); block on the Java Completable. + plugin.close().blockingAwait() + } +} diff --git a/tokt/src/main/java/com/google/adk/tokt/adapters/JavaToolToKt.kt b/tokt/src/main/java/com/google/adk/tokt/adapters/JavaToolToKt.kt new file mode 100644 index 000000000..37c315af6 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/adapters/JavaToolToKt.kt @@ -0,0 +1,111 @@ +/* + * 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.adapters + +import com.google.adk.events.EventActions as JavaEventActions +import com.google.adk.kt.events.EventActions as KtEventActions +import com.google.adk.kt.models.LlmRequest as KtLlmRequest +import com.google.adk.kt.tools.BaseTool +import com.google.adk.kt.tools.ToolContext +import com.google.adk.kt.types.FunctionDeclaration +import com.google.adk.tokt.InteropDispatcher +import com.google.adk.tokt.codecs.FunctionDeclarationCodec +import com.google.adk.tokt.codecs.ToolConfirmationCodec +import com.google.adk.tokt.codecs.reconcileRemovedSentinels +import com.google.adk.tokt.context.ktToolContextToJava +import com.google.adk.tools.BaseTool as JavaBaseTool +import kotlin.jvm.optionals.getOrNull +import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.reactive.awaitSingle +import kotlinx.coroutines.withContext + +/** + * Java -> Kotlin adapter: exposes a Java [JavaBaseTool] as a Kotlin [BaseTool] so the ADK Kotlin + * can invoke a user-authored Java tool. + * + * [run] converts the Kotlin [ToolContext] to a Java one ([ktToolContextToJava]), awaits the tool's + * RxJava `Single` as a coroutine, and returns the result map. The converted context's actions + * delegate live to the Kotlin context, so the tool's state and artifact deltas, and its + * control-flow signals, are visible to the engine. [declaration] exposes the tool's schema + * ([FunctionDeclarationCodec]) so a model can be prompted to call it. + * + * [processLlmRequest] is bridged too, so a Java tool that mutates the outgoing request (e.g. + * [com.google.adk.tools.ExampleTool] injecting few-shot examples, or a built-in tool attaching + * grounding config) takes effect. The tool's resulting contents/config are re-applied to the Kotlin + * request while Kotlin-only fields (model, toolsDict) are preserved. + */ +internal class JavaToolToKt(internal val javaTool: JavaBaseTool) : + BaseTool( + javaTool.name(), + javaTool.description(), + javaTool.longRunning(), + javaTool.customMetadata(), + ) { + + override fun declaration(): FunctionDeclaration? = + javaTool.declaration().map { FunctionDeclarationCodec.fromJava(it) }.getOrNull() + + override suspend fun run(context: ToolContext, args: Map): Any { + val javaContext = ktToolContextToJava(context) + // Run the user's Java tool off the engine dispatcher: RxJava is synchronous by default, so a + // blocking tool must not stall the coroutine driving the agent loop. The live actions view it + // writes is backed by thread-safe (concurrent) Kotlin maps, so the IO thread is safe. + // RxJava Single -> Flowable (reactive-streams Publisher) -> awaitSingle(). + val result = + withContext(InteropDispatcher) { + javaTool.runAsync(args, javaContext).toFlowable().awaitSingle() + } + // No-op when the tool mutated the live actions() view; needed when a tool replaces its actions + // wholesale (setActions(...)) or requests confirmations - copy those back onto the Kotlin + // actions. + copyActionsToKt(javaContext.actions(), context.actions) + return result + } + + override suspend fun processLlmRequest( + toolContext: ToolContext, + llmRequest: KtLlmRequest, + ): KtLlmRequest { + val javaToolContext = ktToolContextToJava(toolContext) + return bridgeProcessLlmRequest(llmRequest) { builder -> + withContext(InteropDispatcher) { + javaTool.processLlmRequest(builder, javaToolContext).toFlowable().awaitFirstOrNull() + } + } + } + + private fun copyActionsToKt(java: JavaEventActions, kt: KtEventActions) { + java.escalate().ifPresent { kt.escalate = it } + java.transferToAgent().ifPresent { kt.transferToAgent = it } + java.skipSummarization().ifPresent { kt.skipSummarization = it } + kt.endOfAgent = kt.endOfAgent || java.endOfAgent() + // Merge deltas only when the tool replaced the actions (otherwise the maps are the same live + // Kotlin maps and are already in sync). + if (java.stateDelta() !== kt.stateDelta) kt.stateDelta.putAll(java.stateDelta()) + if (java.artifactDelta() !== kt.artifactDelta) kt.artifactDelta.putAll(java.artifactDelta()) + // A Java state removal writes the Java sentinel into the (live) delta; map it to the Kotlin one + // so the engine recognizes the deletion. Artifact deltas need no such translation: they are + // plain filename -> version numbers on both sides, with no removal sentinel. + reconcileRemovedSentinels(kt.stateDelta) + // Tool confirmations the tool requested (toolContext.requestConfirmation(...)) live on the Java + // actions only; carry them to the Kotlin actions so the confirmation flow emits the request + // event. + for ((id, confirmation) in java.requestedToolConfirmations()) { + kt.requestedToolConfirmations[id] = ToolConfirmationCodec.fromJava(confirmation) + } + } +} diff --git a/tokt/src/main/java/com/google/adk/tokt/adapters/JavaToolsetToKt.kt b/tokt/src/main/java/com/google/adk/tokt/adapters/JavaToolsetToKt.kt new file mode 100644 index 000000000..7d25c915d --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/adapters/JavaToolsetToKt.kt @@ -0,0 +1,77 @@ +/* + * 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.adapters + +import com.google.adk.kt.agents.ReadonlyContext +import com.google.adk.kt.models.LlmRequest as KtLlmRequest +import com.google.adk.kt.tools.BaseTool +import com.google.adk.kt.tools.ToolContext +import com.google.adk.kt.tools.Toolset +import com.google.adk.tokt.InteropDispatcher +import com.google.adk.tokt.context.KtReadonlyContextToJavaView +import com.google.adk.tokt.context.ktToolContextToJava +import com.google.adk.tools.BaseToolset as JavaBaseToolset +import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.reactive.awaitSingle +import kotlinx.coroutines.withContext + +/** + * Java -> Kotlin adapter: exposes a Java [JavaBaseToolset] as a Kotlin [Toolset] so the ADK Kotlin + * can pull tools from a user-authored Java toolset at request time, with the live [ReadonlyContext] + * ([KtReadonlyContextToJavaView]). Each Java tool the toolset returns is wrapped in a + * [JavaToolToKt]. + * + * Both `getTools` (tool provisioning) and `processLlmRequest` are bridged; the latter lets the Java + * toolset mutate the request's contents/config, which are re-applied to the Kotlin request while + * preserving Kotlin-only fields (model, toolsDict). + */ +internal class JavaToolsetToKt(internal val javaToolset: JavaBaseToolset) : Toolset { + + override suspend fun getTools(readonlyContext: ReadonlyContext?): List { + // ADK Java's BaseToolset.getTools requires a non-null ReadonlyContext (and the engine always + // supplies one); fail fast with a clear message rather than passing null into user code. + val javaContext = + KtReadonlyContextToJavaView( + requireNotNull(readonlyContext) { + "A ReadonlyContext is required to get tools from a Java toolset" + } + ) + // Off the engine dispatcher: toolset discovery (getTools) often does blocking I/O. + // RxJava Flowable -> Single -> Flowable -> awaitSingle(). + val javaTools = + withContext(InteropDispatcher) { + javaToolset.getTools(javaContext).toList().toFlowable().awaitSingle() + } + return javaTools.map { JavaToolToKt(it) } + } + + override suspend fun processLlmRequest( + toolContext: ToolContext, + llmRequest: KtLlmRequest, + ): KtLlmRequest { + val javaToolContext = ktToolContextToJava(toolContext) + return bridgeProcessLlmRequest(llmRequest) { builder -> + withContext(InteropDispatcher) { + javaToolset.processLlmRequest(builder, javaToolContext).toFlowable().awaitFirstOrNull() + } + } + } + + override fun close() { + javaToolset.close() + } +} diff --git a/tokt/src/main/java/com/google/adk/tokt/adapters/KtAgentToJava.kt b/tokt/src/main/java/com/google/adk/tokt/adapters/KtAgentToJava.kt new file mode 100644 index 000000000..641add818 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/adapters/KtAgentToJava.kt @@ -0,0 +1,63 @@ +/* + * 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.adapters + +import com.google.adk.agents.BaseAgent as JavaBaseAgent +import com.google.adk.agents.InvocationContext as JavaInvocationContext +import com.google.adk.events.Event as JavaEvent +import com.google.adk.kt.agents.BaseAgent as KtBaseAgent +import io.reactivex.rxjava3.core.Flowable + +/** + * An inspection-only Java view of a Kotlin [KtBaseAgent]. It exists solely so Java plugin callbacks + * (handed the running engine agent via [ktAgentAsJava]) can read it as an ADK Java [JavaBaseAgent]: + * name, description, and the sub-agent tree (each child wrapped the same way, so `subAgents()` and + * a downward `findAgent()` reflect the real tree). The wrapper is built from the *running* agent + * downwards, so `parentAgent()` is null and `rootAgent()` returns that agent rather than the true + * root - Kotlin's `parentAgent`/`rootAgent` are `internal`, so this module cannot wrap from above. + * It is not meant to be executed: the Kotlin runner is the sole driver of agent execution, so both + * [runAsyncImpl] and [runLiveImpl] throw. A plugin must not run the agent it is given. The agent's + * own callbacks are intentionally not exposed (they are lifecycle hooks, not inspection data). + */ +internal class KtAgentToJava(ktAgent: KtBaseAgent) : + JavaBaseAgent( + ktAgent.name, + ktAgent.description, + ktAgent.subAgents.map { KtAgentToJava(it) }, + null, + null, + ) { + + override fun runAsyncImpl(invocationContext: JavaInvocationContext): Flowable = + Flowable.error(unsupported()) + + override fun runLiveImpl(invocationContext: JavaInvocationContext): Flowable = + Flowable.error(unsupported()) + + private fun unsupported() = + UnsupportedOperationException( + "This is an inspection-only view of a Kotlin engine agent, handed to Java plugin callbacks " + + "via ktAgentAsJava; the Kotlin runner is the sole driver of agent execution, so it cannot " + + "be run from Java." + ) +} + +/** + * Wraps a Kotlin [ktAgent] as an inspection-only Java [JavaBaseAgent] view for plugin callbacks. + * The result exposes the agent's metadata but must not be executed (see [KtAgentToJava]). + */ +internal fun ktAgentAsJava(ktAgent: KtBaseAgent): JavaBaseAgent = KtAgentToJava(ktAgent) diff --git a/tokt/src/main/java/com/google/adk/tokt/adapters/ProcessLlmRequestBridge.kt b/tokt/src/main/java/com/google/adk/tokt/adapters/ProcessLlmRequestBridge.kt new file mode 100644 index 000000000..8990cca86 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/adapters/ProcessLlmRequestBridge.kt @@ -0,0 +1,53 @@ +/* + * 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.adapters + +import com.google.adk.kt.models.LlmRequest as KtLlmRequest +import com.google.adk.models.LlmRequest as JavaLlmRequest +import com.google.adk.tokt.codecs.ContentCodec +import com.google.adk.tokt.codecs.GenerateContentConfigCodec +import com.google.adk.tokt.codecs.LlmRequestCodec +import kotlin.jvm.optionals.getOrNull + +/** + * Re-applies the (possibly mutated) contents/config of a Java [javaResult] onto the original Kotlin + * [request], preserving Kotlin-only fields the Java view cannot carry (model, toolsDict, + * cacheConfig, cacheMetadata, cacheableContentsTokenCount). Use this instead of + * [LlmRequestCodec.fromJava], which builds a fresh request and drops those fields. + */ +internal fun reapplyJavaRequest(request: KtLlmRequest, javaResult: JavaLlmRequest): KtLlmRequest = + request.copy( + contents = javaResult.contents().map { ContentCodec.fromJava(it) }, + config = + javaResult.config().map { GenerateContentConfigCodec.fromJava(it) }.getOrNull() + ?: request.config, + ) + +/** + * Shared bridge for a Java tool's / toolset's `processLlmRequest`: converts the Kotlin request to a + * Java builder, runs [applyJavaMutation] (the Java `processLlmRequest`, which the caller runs off + * the engine dispatcher), then re-applies the mutated contents/config back onto the Kotlin request + * while preserving Kotlin-only fields. + */ +internal suspend fun bridgeProcessLlmRequest( + llmRequest: KtLlmRequest, + applyJavaMutation: suspend (JavaLlmRequest.Builder) -> Unit, +): KtLlmRequest { + val builder = LlmRequestCodec.toJava(llmRequest).toBuilder() + applyJavaMutation(builder) + return reapplyJavaRequest(llmRequest, builder.build()) +} diff --git a/tokt/src/main/java/com/google/adk/tokt/codecs/ContentCodec.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/ContentCodec.kt new file mode 100644 index 000000000..760a93253 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/ContentCodec.kt @@ -0,0 +1,44 @@ +/* + * 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.codecs + +import com.google.adk.kt.types.Content as KtContent +import com.google.genai.types.Content as GenaiContent +import kotlin.jvm.optionals.getOrNull + +/** + * Converts [Content][KtContent] between the ADK Kotlin and the genai `Content` that the ADK Java + * facade exposes on events and requests. The role is carried here; each part is converted via + * [PartCodec]. + */ +internal object ContentCodec { + + /** Returns the genai [GenaiContent] view of the Kotlin [content]. */ + fun toJava(content: KtContent): GenaiContent { + val parts = content.parts.mapNotNull { PartCodec.toJava(it) } + val builder = GenaiContent.builder() + content.role?.let { builder.role(it) } + builder.parts(parts) + return builder.build() + } + + /** Returns the Kotlin [KtContent] view of the genai [content]. */ + fun fromJava(content: GenaiContent): KtContent { + val parts = content.parts().getOrNull().orEmpty().mapNotNull { PartCodec.fromJava(it) } + return KtContent(role = content.role().getOrNull(), parts = parts) + } +} diff --git a/tokt/src/main/java/com/google/adk/tokt/codecs/CustomMetadataCodec.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/CustomMetadataCodec.kt new file mode 100644 index 000000000..bae319095 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/CustomMetadataCodec.kt @@ -0,0 +1,62 @@ +/* + * 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.codecs + +import com.google.genai.types.CustomMetadata as GenaiCustomMetadata +import com.google.genai.types.StringList as GenaiStringList +import kotlin.jvm.optionals.getOrNull + +/** + * Bridges the Kotlin `customMetadata` (a `Map` on the Kotlin `LlmResponse` and + * `Event`) and the ADK Java facade's `List` (each a key plus one typed value). + * Shared by [LlmResponseCodec] and [EventCodec]. + * + * A genai [CustomMetadata][GenaiCustomMetadata] can only hold a string, a float, or a list of + * strings, so a Kotlin value outside those (e.g. a boolean or nested object) is stored via its + * `toString()` and a non-`Float` number is narrowed to `Float`; entries without a key are dropped. + */ +internal object CustomMetadataCodec { + + /** Returns the Java `List` view of the Kotlin [metadata] map. */ + fun toJava(metadata: Map): List = + metadata.map { (key, value) -> + val builder = GenaiCustomMetadata.builder().key(key) + when (value) { + is String -> builder.stringValue(value) + is Number -> builder.numericValue(value.toFloat()) + is Collection<*> -> + builder.stringListValue( + GenaiStringList.builder().values(value.map { it.toString() }).build() + ) + null -> {} + else -> builder.stringValue(value.toString()) + } + builder.build() + } + + /** Returns the Kotlin map view of the Java [list], keyed by each entry's key. */ + fun fromJava(list: List): Map = buildMap { + for (entry in list) { + val key = entry.key().getOrNull() ?: continue + val value: Any? = + entry.stringValue().getOrNull() + ?: entry.numericValue().getOrNull() + ?: entry.stringListValue().getOrNull()?.values()?.getOrNull() + if (value != null) put(key, value) + } + } +} diff --git a/tokt/src/main/java/com/google/adk/tokt/codecs/EnumCodec.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/EnumCodec.kt new file mode 100644 index 000000000..4f97423d8 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/EnumCodec.kt @@ -0,0 +1,31 @@ +/* + * 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.codecs + +import com.google.adk.kt.types.FinishReason as KtFinishReason +import com.google.genai.types.FinishReason as GenaiFinishReason +import kotlin.enums.enumEntries + +/** + * The [K] constant with this [name], or null when the name is absent or has no such constant. The + * genai and Kotlin enums share constant names, so this bridges them by name. + */ +internal inline fun > enumByNameOrNull(name: String?): K? = + enumEntries().firstOrNull { it.name == name } + +/** The Kotlin finish reason as a genai finish reason (matched by name). */ +internal fun KtFinishReason.toGenai(): GenaiFinishReason = GenaiFinishReason(name) diff --git a/tokt/src/main/java/com/google/adk/tokt/codecs/EventCodec.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/EventCodec.kt new file mode 100644 index 000000000..f9a625d23 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/EventCodec.kt @@ -0,0 +1,120 @@ +/* + * 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.codecs + +import com.google.adk.events.Event as JavaEvent +import com.google.adk.events.EventActions as JavaEventActions +import com.google.adk.kt.events.Event as KtEvent +import com.google.adk.kt.events.EventActions as KtEventActions +import com.google.adk.kt.ids.Uuid +import com.google.adk.kt.types.FinishReason as KtFinishReason +import com.google.genai.types.FinishReason as GenaiFinishReason +import kotlin.jvm.optionals.getOrNull + +/** + * Converts an event between the ADK Kotlin [KtEvent] and the ADK Java facade [JavaEvent] that a + * Java `Runner` and the session service exchange. + * + * Carries the identity (`id`, `invocationId`, `author`, `branch`), the [content][ContentCodec], the + * [actions][KtEventActions] (state/artifact deltas, the control-flow flags - transfer, escalate, + * skipSummarization, endOfAgent - the requested tool confirmations, and the context-compaction + * summary), the streaming/error signals (`partial`, `turnComplete`, `interrupted`, `errorMessage`, + * `errorCode`, `finishReason`), the token `usageMetadata` ([UsageMetadataCodec]) and + * `groundingMetadata` ([GroundingMetadataCodec]), long-running tool ids, and the timestamp. + */ +internal object EventCodec { + + /** + * Returns the Java [JavaEvent] view of the Kotlin [event]. + * + * `EventActions.agentState` and `rewindBeforeInvocationId` are dropped: ADK Java has no field for + * either. Behind a persistent Java session service that costs resumability, since the engine + * reads its resumption state back from the stored event. + */ + fun toJava(event: KtEvent): JavaEvent { + val builder = + JavaEvent.builder() + .id(event.id) + .author(event.author) + .actions(eventActionsToJava(event.actions)) + event.invocationId?.let { builder.invocationId(it) } + event.branch?.let { builder.branch(it) } + event.content?.let { builder.content(ContentCodec.toJava(it)) } + if (event.longRunningToolIds.isNotEmpty()) builder.longRunningToolIds(event.longRunningToolIds) + // Streaming/error signals are set only when meaningful, so ordinary events keep the Java + // Optional.empty() defaults. + if (event.partial) builder.partial(true) + if (event.turnComplete) builder.turnComplete(true) + if (event.interrupted) builder.interrupted(true) + event.errorMessage?.let { builder.errorMessage(it) } + event.errorCode?.let { builder.errorCode(GenaiFinishReason(it)) } + event.finishReason?.let { builder.finishReason(it.toGenai()) } + event.usageMetadata?.let { builder.usageMetadata(UsageMetadataCodec.toJava(it)) } + event.avgLogProbs?.let { builder.avgLogprobs(it) } + event.groundingMetadata?.let { builder.groundingMetadata(GroundingMetadataCodec.toJava(it)) } + event.modelVersion?.let { builder.modelVersion(it) } + event.customMetadata?.let { builder.customMetadata(CustomMetadataCodec.toJava(it)) } + builder.timestamp(event.timestamp) + return builder.build() + } + + /** Returns the Kotlin [KtEvent] view of the Java [event]. */ + fun fromJava(event: JavaEvent): KtEvent { + // One null check instead of eleven: an absent actions object behaves like an empty one. + val javaActions = event.actions() ?: JavaEventActions() + return KtEvent( + id = event.id() ?: Uuid.random(), + invocationId = event.invocationId(), + author = event.author() ?: "", + content = event.content().getOrNull()?.let { ContentCodec.fromJava(it) }, + longRunningToolIds = event.longRunningToolIds().getOrNull() ?: emptySet(), + actions = + KtEventActions( + stateDelta = stateDeltaFromJava(javaActions.stateDelta()), + artifactDelta = javaActions.artifactDelta().toMutableMap(), + transferToAgent = javaActions.transferToAgent().getOrNull(), + escalate = javaActions.escalate().getOrNull() ?: false, + skipSummarization = javaActions.skipSummarization().getOrNull() ?: false, + endOfAgent = javaActions.endOfAgent(), + requestedToolConfirmations = + javaActions + .requestedToolConfirmations() + .mapValues { ToolConfirmationCodec.fromJava(it.value) } + .toMutableMap(), + compaction = + javaActions.compaction().getOrNull()?.let { EventCompactionCodec.fromJava(it) }, + ), + branch = event.branch().getOrNull(), + partial = event.partial().getOrNull() ?: false, + turnComplete = event.turnComplete().getOrNull() ?: false, + interrupted = event.interrupted().getOrNull() ?: false, + errorMessage = event.errorMessage().getOrNull(), + // toString() preserves the raw value; knownEnum().name would collapse an unrecognized code to + // FINISH_REASON_UNSPECIFIED (errorCode is a free-form String on the Kotlin side). + errorCode = event.errorCode().getOrNull()?.toString(), + finishReason = + enumByNameOrNull(event.finishReason().getOrNull()?.knownEnum()?.name), + usageMetadata = event.usageMetadata().getOrNull()?.let { UsageMetadataCodec.fromJava(it) }, + avgLogProbs = event.avgLogprobs().getOrNull(), + groundingMetadata = + event.groundingMetadata().getOrNull()?.let { GroundingMetadataCodec.fromJava(it) }, + modelVersion = event.modelVersion().getOrNull(), + customMetadata = event.customMetadata().getOrNull()?.let { CustomMetadataCodec.fromJava(it) }, + timestamp = event.timestamp(), + ) + } +} diff --git a/tokt/src/main/java/com/google/adk/tokt/codecs/EventCompactionCodec.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/EventCompactionCodec.kt new file mode 100644 index 000000000..3f1666438 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/EventCompactionCodec.kt @@ -0,0 +1,42 @@ +/* + * 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.codecs + +import com.google.adk.events.EventCompaction as JavaEventCompaction +import com.google.adk.kt.events.EventCompaction as KtEventCompaction + +/** + * Converts the context-compaction summary an event carries on its `actions.compaction` between the + * Kotlin [KtEventCompaction] and the Java [JavaEventCompaction], so a compaction summary survives + * the session round-trip (the summary text lives here, not in the event content). + */ +internal object EventCompactionCodec { + + fun toJava(compaction: KtEventCompaction): JavaEventCompaction = + JavaEventCompaction.builder() + .startTimestamp(compaction.startTimestamp) + .endTimestamp(compaction.endTimestamp) + .compactedContent(ContentCodec.toJava(compaction.compactedContent)) + .build() + + fun fromJava(compaction: JavaEventCompaction): KtEventCompaction = + KtEventCompaction( + startTimestamp = compaction.startTimestamp(), + endTimestamp = compaction.endTimestamp(), + compactedContent = ContentCodec.fromJava(compaction.compactedContent()), + ) +} diff --git a/tokt/src/main/java/com/google/adk/tokt/codecs/FunctionDeclarationCodec.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/FunctionDeclarationCodec.kt new file mode 100644 index 000000000..c24e7d1fe --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/FunctionDeclarationCodec.kt @@ -0,0 +1,48 @@ +/* + * 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.codecs + +import com.google.adk.kt.types.FunctionDeclaration as KtFunctionDeclaration +import com.google.genai.types.FunctionDeclaration as GenaiFunctionDeclaration +import kotlin.jvm.optionals.getOrNull + +/** + * Converts a tool's [FunctionDeclaration][KtFunctionDeclaration] between the genai type the ADK + * Java facade exposes (`BaseTool.declaration()`) and the Kotlin `kt.types.FunctionDeclaration`. + * + * Lets the Kotlin see a Java tool's schema so a model can be prompted to call it, enabling + * LLM-driven tool calling through [JavaToolToKt][com.google.adk.tokt.adapters.JavaToolToKt]. The + * parameter `Schema` is carried by [SchemaCodec]. + */ +internal object FunctionDeclarationCodec { + + /** Returns the Kotlin [KtFunctionDeclaration] view of the genai [declaration]. */ + fun fromJava(declaration: GenaiFunctionDeclaration): KtFunctionDeclaration = + KtFunctionDeclaration( + name = declaration.name().getOrNull() ?: "", + description = declaration.description().getOrNull() ?: "", + parameters = declaration.parameters().getOrNull()?.let { SchemaCodec.fromJava(it) }, + ) + + /** Returns the genai [GenaiFunctionDeclaration] view of the Kotlin [declaration]. */ + fun toJava(declaration: KtFunctionDeclaration): GenaiFunctionDeclaration { + val builder = + GenaiFunctionDeclaration.builder().name(declaration.name).description(declaration.description) + declaration.parameters?.let { builder.parameters(SchemaCodec.toJava(it)) } + return builder.build() + } +} diff --git a/tokt/src/main/java/com/google/adk/tokt/codecs/GenerateContentConfigCodec.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/GenerateContentConfigCodec.kt new file mode 100644 index 000000000..cdbba3505 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/GenerateContentConfigCodec.kt @@ -0,0 +1,371 @@ +/* + * 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.codecs + +import com.google.adk.kt.types.FunctionCallingConfig as KtFunctionCallingConfig +import com.google.adk.kt.types.GenerateContentConfig as KtConfig +import com.google.adk.kt.types.GenerationConfigRoutingConfig as KtRoutingConfig +import com.google.adk.kt.types.GenerationConfigRoutingConfigAutoRoutingMode as KtAutoRoutingMode +import com.google.adk.kt.types.GenerationConfigRoutingConfigManualRoutingMode as KtManualRoutingMode +import com.google.adk.kt.types.GoogleMaps as KtGoogleMaps +import com.google.adk.kt.types.GoogleSearch as KtGoogleSearch +import com.google.adk.kt.types.HarmBlockThreshold as KtHarmBlockThreshold +import com.google.adk.kt.types.HarmCategory as KtHarmCategory +import com.google.adk.kt.types.MediaResolution as KtMediaResolution +import com.google.adk.kt.types.ModelRoutingPreference as KtModelRoutingPreference +import com.google.adk.kt.types.Retrieval as KtRetrieval +import com.google.adk.kt.types.SafetySetting as KtSafetySetting +import com.google.adk.kt.types.ServiceTier as KtServiceTier +import com.google.adk.kt.types.ThinkingConfig as KtThinkingConfig +import com.google.adk.kt.types.ThinkingLevel as KtThinkingLevel +import com.google.adk.kt.types.Tool as KtTool +import com.google.adk.kt.types.ToolConfig as KtToolConfig +import com.google.adk.kt.types.UrlContext as KtUrlContext +import com.google.adk.kt.types.VertexAISearch as KtVertexAISearch +import com.google.adk.kt.types.VertexAISearchDataStoreSpec as KtVertexAISearchDataStoreSpec +import com.google.adk.kt.types.VertexRagStore as KtVertexRagStore +import com.google.adk.kt.types.VertexRagStoreRagResource as KtVertexRagStoreRagResource +import com.google.genai.types.FunctionCallingConfig as GenaiFunctionCallingConfig +import com.google.genai.types.GenerateContentConfig as GenaiConfig +import com.google.genai.types.GenerationConfigRoutingConfig as GenaiRoutingConfig +import com.google.genai.types.GenerationConfigRoutingConfigAutoRoutingMode as GenaiAutoRoutingMode +import com.google.genai.types.GenerationConfigRoutingConfigManualRoutingMode as GenaiManualRoutingMode +import com.google.genai.types.GoogleMaps as GenaiGoogleMaps +import com.google.genai.types.GoogleSearch as GenaiGoogleSearch +import com.google.genai.types.HarmBlockThreshold as GenaiHarmBlockThreshold +import com.google.genai.types.HarmCategory as GenaiHarmCategory +import com.google.genai.types.MediaResolution as GenaiMediaResolution +import com.google.genai.types.Retrieval as GenaiRetrieval +import com.google.genai.types.SafetySetting as GenaiSafetySetting +import com.google.genai.types.ServiceTier as GenaiServiceTier +import com.google.genai.types.ThinkingConfig as GenaiThinkingConfig +import com.google.genai.types.ThinkingLevel as GenaiThinkingLevel +import com.google.genai.types.Tool as GenaiTool +import com.google.genai.types.ToolConfig as GenaiToolConfig +import com.google.genai.types.UrlContext as GenaiUrlContext +import com.google.genai.types.VertexAISearch as GenaiVertexAISearch +import com.google.genai.types.VertexAISearchDataStoreSpec as GenaiVertexAISearchDataStoreSpec +import com.google.genai.types.VertexRagStore as GenaiVertexRagStore +import com.google.genai.types.VertexRagStoreRagResource as GenaiVertexRagStoreRagResource +import kotlin.jvm.optionals.getOrNull + +/** + * Converts the Kotlin `kt.types.GenerateContentConfig` and the genai `GenerateContentConfig` that + * an ADK Java `BaseLlm` consumes ([LlmRequestCodec]), in both directions. + * + * Carries the system instruction, cached content name, tools (function declarations, Google Search, + * Google Maps, and both the Vertex AI Search and Vertex RAG store retrieval kinds), the common + * generation parameters, thinking config, model routing config, tool config, and safety settings. + * genai fields the Kotlin types cannot represent (`FunctionCallingConfig.mode`, + * `SafetySetting.method`) are not mapped. + */ +internal object GenerateContentConfigCodec { + + /** Returns the genai [GenaiConfig] view of the Kotlin [config]. */ + fun toJava(config: KtConfig): GenaiConfig { + val builder = GenaiConfig.builder() + config.systemInstruction?.let { builder.systemInstruction(ContentCodec.toJava(it)) } + config.cachedContent?.let { builder.cachedContent(it) } + config.tools?.let { tools -> builder.tools(tools.map { toolToJava(it) }) } + config.temperature?.let { builder.temperature(it) } + config.topP?.let { builder.topP(it) } + config.topK?.let { builder.topK(it.toFloat()) } // genai types topK as Float. + config.candidateCount?.let { builder.candidateCount(it) } + config.maxOutputTokens?.let { builder.maxOutputTokens(it) } + config.stopSequences?.let { builder.stopSequences(it) } + config.responseMimeType?.let { builder.responseMimeType(it) } + config.responseSchema?.let { builder.responseSchema(SchemaCodec.toJava(it)) } + config.thinkingConfig?.let { builder.thinkingConfig(thinkingConfigToJava(it)) } + config.labels?.let { builder.labels(it) } + config.presencePenalty?.let { builder.presencePenalty(it) } + config.frequencyPenalty?.let { builder.frequencyPenalty(it) } + config.responseLogprobs?.let { builder.responseLogprobs(it) } + config.mediaResolution?.let { builder.mediaResolution(GenaiMediaResolution(it.name)) } + // Via Known, not the raw name: genai's ServiceTier keeps a String verbatim and its wire value + // is lowercase ("standard"), so GenaiServiceTier(it.name) would serialize "STANDARD". + config.serviceTier?.let { + builder.serviceTier(GenaiServiceTier(GenaiServiceTier.Known.valueOf(it.name))) + } + config.routingConfig?.let { builder.routingConfig(routingConfigToJava(it)) } + config.toolConfig?.let { builder.toolConfig(toolConfigToJava(it)) } + config.safetySettings?.let { settings -> + builder.safetySettings(settings.map { safetySettingToJava(it) }) + } + return builder.build() + } + + /** Returns the Kotlin [KtConfig] view of the genai [config]. */ + fun fromJava(config: GenaiConfig): KtConfig = + KtConfig( + tools = config.tools().getOrNull()?.map { toolFromJava(it) }, + systemInstruction = config.systemInstruction().getOrNull()?.let { ContentCodec.fromJava(it) }, + cachedContent = config.cachedContent().getOrNull(), + temperature = config.temperature().getOrNull(), + topP = config.topP().getOrNull(), + topK = config.topK().getOrNull()?.toInt(), + candidateCount = config.candidateCount().getOrNull(), + maxOutputTokens = config.maxOutputTokens().getOrNull(), + stopSequences = config.stopSequences().getOrNull(), + responseMimeType = config.responseMimeType().getOrNull(), + responseSchema = config.responseSchema().getOrNull()?.let { SchemaCodec.fromJava(it) }, + thinkingConfig = config.thinkingConfig().getOrNull()?.let { thinkingConfigFromJava(it) }, + labels = config.labels().getOrNull(), + presencePenalty = config.presencePenalty().getOrNull(), + frequencyPenalty = config.frequencyPenalty().getOrNull(), + responseLogprobs = config.responseLogprobs().getOrNull(), + mediaResolution = + enumByNameOrNull( + config.mediaResolution().getOrNull()?.knownEnum()?.name + ), + serviceTier = + enumByNameOrNull(config.serviceTier().getOrNull()?.knownEnum()?.name), + routingConfig = config.routingConfig().getOrNull()?.let { routingConfigFromJava(it) }, + toolConfig = config.toolConfig().getOrNull()?.let { toolConfigFromJava(it) }, + safetySettings = config.safetySettings().getOrNull()?.map { safetySettingFromJava(it) }, + ) + + private fun toolToJava(tool: KtTool): GenaiTool { + val builder = GenaiTool.builder() + tool.functionDeclarations?.let { decls -> + builder.functionDeclarations(decls.map { FunctionDeclarationCodec.toJava(it) }) + } + tool.googleSearch?.let { builder.googleSearch(googleSearchToJava(it)) } + tool.googleMaps?.let { builder.googleMaps(googleMapsToJava(it)) } + tool.retrieval?.let { builder.retrieval(retrievalToJava(it)) } + tool.urlContext?.let { builder.urlContext(GenaiUrlContext.builder().build()) } + return builder.build() + } + + private fun toolFromJava(tool: GenaiTool): KtTool = + KtTool( + functionDeclarations = + tool.functionDeclarations().getOrNull()?.map { FunctionDeclarationCodec.fromJava(it) }, + googleSearch = tool.googleSearch().getOrNull()?.let { googleSearchFromJava(it) }, + googleMaps = tool.googleMaps().getOrNull()?.let { googleMapsFromJava(it) }, + retrieval = tool.retrieval().getOrNull()?.let { retrievalFromJava(it) }, + urlContext = tool.urlContext().getOrNull()?.let { KtUrlContext() }, + ) + + private fun googleSearchToJava(search: KtGoogleSearch): GenaiGoogleSearch { + val builder = GenaiGoogleSearch.builder() + search.excludeDomains.takeIf { it.isNotEmpty() }?.let { builder.excludeDomains(it) } + return builder.build() + } + + private fun googleSearchFromJava(search: GenaiGoogleSearch): KtGoogleSearch = + KtGoogleSearch(excludeDomains = search.excludeDomains().getOrNull().orEmpty()) + + private fun googleMapsToJava(maps: KtGoogleMaps): GenaiGoogleMaps { + val builder = GenaiGoogleMaps.builder() + maps.enableWidget?.let { builder.enableWidget(it) } + return builder.build() + } + + private fun googleMapsFromJava(maps: GenaiGoogleMaps): KtGoogleMaps = + KtGoogleMaps(enableWidget = maps.enableWidget().getOrNull()) + + // Carries the Vertex AI Search and Vertex RAG store retrieval kinds; genai's disableAttribution + // and externalApi have no Kotlin field and are dropped. + private fun retrievalToJava(retrieval: KtRetrieval): GenaiRetrieval { + val builder = GenaiRetrieval.builder() + retrieval.vertexAiSearch?.let { builder.vertexAiSearch(vertexAiSearchToJava(it)) } + retrieval.vertexRagStore?.let { builder.vertexRagStore(vertexRagStoreToJava(it)) } + return builder.build() + } + + private fun retrievalFromJava(retrieval: GenaiRetrieval): KtRetrieval = + KtRetrieval( + vertexAiSearch = retrieval.vertexAiSearch().getOrNull()?.let { vertexAiSearchFromJava(it) }, + vertexRagStore = retrieval.vertexRagStore().getOrNull()?.let { vertexRagStoreFromJava(it) }, + ) + + private fun vertexRagStoreToJava(store: KtVertexRagStore): GenaiVertexRagStore { + val builder = GenaiVertexRagStore.builder() + store.ragCorpora?.let { builder.ragCorpora(it) } + store.ragResources?.let { resources -> + builder.ragResources(resources.map { ragResourceToJava(it) }) + } + store.similarityTopK?.let { builder.similarityTopK(it) } + store.vectorDistanceThreshold?.let { builder.vectorDistanceThreshold(it) } + return builder.build() + } + + private fun vertexRagStoreFromJava(store: GenaiVertexRagStore): KtVertexRagStore = + KtVertexRagStore( + ragCorpora = store.ragCorpora().getOrNull(), + ragResources = store.ragResources().getOrNull()?.map { ragResourceFromJava(it) }, + similarityTopK = store.similarityTopK().getOrNull(), + vectorDistanceThreshold = store.vectorDistanceThreshold().getOrNull(), + ) + + private fun ragResourceToJava( + resource: KtVertexRagStoreRagResource + ): GenaiVertexRagStoreRagResource { + val builder = GenaiVertexRagStoreRagResource.builder() + resource.ragCorpus?.let { builder.ragCorpus(it) } + resource.ragFileIds?.let { builder.ragFileIds(it) } + return builder.build() + } + + private fun ragResourceFromJava( + resource: GenaiVertexRagStoreRagResource + ): KtVertexRagStoreRagResource = + KtVertexRagStoreRagResource( + ragCorpus = resource.ragCorpus().getOrNull(), + ragFileIds = resource.ragFileIds().getOrNull(), + ) + + private fun routingConfigToJava(config: KtRoutingConfig): GenaiRoutingConfig { + val builder = GenaiRoutingConfig.builder() + config.autoMode?.let { builder.autoMode(autoRoutingModeToJava(it)) } + config.manualMode?.let { builder.manualMode(manualRoutingModeToJava(it)) } + return builder.build() + } + + private fun routingConfigFromJava(config: GenaiRoutingConfig): KtRoutingConfig = + KtRoutingConfig( + autoMode = config.autoMode().getOrNull()?.let { autoRoutingModeFromJava(it) }, + manualMode = config.manualMode().getOrNull()?.let { manualRoutingModeFromJava(it) }, + ) + + private fun autoRoutingModeToJava(mode: KtAutoRoutingMode): GenaiAutoRoutingMode { + val builder = GenaiAutoRoutingMode.builder() + mode.modelRoutingPreference?.let { builder.modelRoutingPreference(it.name) } + return builder.build() + } + + private fun autoRoutingModeFromJava(mode: GenaiAutoRoutingMode): KtAutoRoutingMode = + KtAutoRoutingMode( + modelRoutingPreference = + enumByNameOrNull( + mode.modelRoutingPreference().getOrNull()?.knownEnum()?.name + ) + ) + + private fun manualRoutingModeToJava(mode: KtManualRoutingMode): GenaiManualRoutingMode { + val builder = GenaiManualRoutingMode.builder() + mode.modelName?.let { builder.modelName(it) } + return builder.build() + } + + private fun manualRoutingModeFromJava(mode: GenaiManualRoutingMode): KtManualRoutingMode = + KtManualRoutingMode(modelName = mode.modelName().getOrNull()) + + private fun vertexAiSearchToJava(search: KtVertexAISearch): GenaiVertexAISearch { + val builder = GenaiVertexAISearch.builder() + search.datastore?.let { builder.datastore(it) } + search.engine?.let { builder.engine(it) } + search.filter?.let { builder.filter(it) } + search.maxResults?.let { builder.maxResults(it) } + search.dataStoreSpecs?.let { specs -> + builder.dataStoreSpecs(specs.map { dataStoreSpecToJava(it) }) + } + return builder.build() + } + + private fun vertexAiSearchFromJava(search: GenaiVertexAISearch): KtVertexAISearch = + KtVertexAISearch( + dataStoreSpecs = search.dataStoreSpecs().getOrNull()?.map { dataStoreSpecFromJava(it) }, + datastore = search.datastore().getOrNull(), + engine = search.engine().getOrNull(), + filter = search.filter().getOrNull(), + maxResults = search.maxResults().getOrNull(), + ) + + private fun dataStoreSpecToJava( + spec: KtVertexAISearchDataStoreSpec + ): GenaiVertexAISearchDataStoreSpec { + val builder = GenaiVertexAISearchDataStoreSpec.builder() + spec.dataStore?.let { builder.dataStore(it) } + spec.filter?.let { builder.filter(it) } + return builder.build() + } + + private fun dataStoreSpecFromJava( + spec: GenaiVertexAISearchDataStoreSpec + ): KtVertexAISearchDataStoreSpec = + KtVertexAISearchDataStoreSpec( + dataStore = spec.dataStore().getOrNull(), + filter = spec.filter().getOrNull(), + ) + + private fun thinkingConfigToJava(config: KtThinkingConfig): GenaiThinkingConfig { + val builder = GenaiThinkingConfig.builder() + config.includeThoughts?.let { builder.includeThoughts(it) } + config.thinkingBudget?.let { builder.thinkingBudget(it) } + config.thinkingLevel?.let { builder.thinkingLevel(GenaiThinkingLevel(it.name)) } + return builder.build() + } + + private fun thinkingConfigFromJava(config: GenaiThinkingConfig): KtThinkingConfig = + KtThinkingConfig( + includeThoughts = config.includeThoughts().getOrNull(), + thinkingBudget = config.thinkingBudget().getOrNull(), + thinkingLevel = + enumByNameOrNull(config.thinkingLevel().getOrNull()?.knownEnum()?.name), + ) + + // The Kotlin FunctionCallingConfig models allowedFunctionNames and streamFunctionCallArguments; + // genai's `mode` has no Kotlin field and is dropped. + private fun toolConfigToJava(config: KtToolConfig): GenaiToolConfig { + val builder = GenaiToolConfig.builder() + config.functionCallingConfig?.let { + builder.functionCallingConfig(functionCallingConfigToJava(it)) + } + return builder.build() + } + + private fun toolConfigFromJava(config: GenaiToolConfig): KtToolConfig = + KtToolConfig( + functionCallingConfig = + config.functionCallingConfig().getOrNull()?.let { functionCallingConfigFromJava(it) } + ) + + private fun functionCallingConfigToJava( + config: KtFunctionCallingConfig + ): GenaiFunctionCallingConfig { + val builder = GenaiFunctionCallingConfig.builder() + config.allowedFunctionNames?.let { builder.allowedFunctionNames(it) } + config.streamFunctionCallArguments?.let { builder.streamFunctionCallArguments(it) } + return builder.build() + } + + private fun functionCallingConfigFromJava( + config: GenaiFunctionCallingConfig + ): KtFunctionCallingConfig = + KtFunctionCallingConfig( + allowedFunctionNames = config.allowedFunctionNames().getOrNull(), + streamFunctionCallArguments = config.streamFunctionCallArguments().getOrNull(), + ) + + // The Kotlin SafetySetting models category + threshold; genai's `method` has no Kotlin field and + // is dropped (a Kotlin gap, not a bridge one). + private fun safetySettingToJava(setting: KtSafetySetting): GenaiSafetySetting { + val builder = GenaiSafetySetting.builder() + setting.category?.let { builder.category(GenaiHarmCategory(it.name)) } + setting.threshold?.let { builder.threshold(GenaiHarmBlockThreshold(it.name)) } + return builder.build() + } + + private fun safetySettingFromJava(setting: GenaiSafetySetting): KtSafetySetting = + KtSafetySetting( + category = + enumByNameOrNull(setting.category().getOrNull()?.knownEnum()?.name), + threshold = + enumByNameOrNull(setting.threshold().getOrNull()?.knownEnum()?.name), + ) +} diff --git a/tokt/src/main/java/com/google/adk/tokt/codecs/GroundingMetadataCodec.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/GroundingMetadataCodec.kt new file mode 100644 index 000000000..509976dd7 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/GroundingMetadataCodec.kt @@ -0,0 +1,193 @@ +/* + * 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.codecs + +import com.google.adk.kt.types.GroundingChunk as KtGroundingChunk +import com.google.adk.kt.types.GroundingChunkMaps as KtGroundingChunkMaps +import com.google.adk.kt.types.GroundingChunkRetrievedContext as KtGroundingChunkRetrievedContext +import com.google.adk.kt.types.GroundingChunkWeb as KtGroundingChunkWeb +import com.google.adk.kt.types.GroundingMetadata as KtGroundingMetadata +import com.google.adk.kt.types.GroundingSupport as KtGroundingSupport +import com.google.adk.kt.types.RetrievalMetadata as KtRetrievalMetadata +import com.google.adk.kt.types.SearchEntryPoint as KtSearchEntryPoint +import com.google.adk.kt.types.Segment as KtSegment +import com.google.genai.types.GroundingChunk as GenaiGroundingChunk +import com.google.genai.types.GroundingChunkMaps as GenaiGroundingChunkMaps +import com.google.genai.types.GroundingChunkRetrievedContext as GenaiGroundingChunkRetrievedContext +import com.google.genai.types.GroundingChunkWeb as GenaiGroundingChunkWeb +import com.google.genai.types.GroundingMetadata as GenaiGroundingMetadata +import com.google.genai.types.GroundingSupport as GenaiGroundingSupport +import com.google.genai.types.RetrievalMetadata as GenaiRetrievalMetadata +import com.google.genai.types.SearchEntryPoint as GenaiSearchEntryPoint +import com.google.genai.types.Segment as GenaiSegment +import kotlin.jvm.optionals.getOrNull + +/** + * Converts grounding metadata between the genai [GroundingMetadata][GenaiGroundingMetadata] the ADK + * Java facade exposes and the Kotlin `kt.types.GroundingMetadata`, in both directions: image/web + * search queries, grounding chunks (web / retrieved context / maps), grounding supports (segment, + * chunk indices, confidence scores), the search entry point, and retrieval metadata. genai-only + * fields the Kotlin type does not model are dropped, including a grounding chunk's `image`, and a + * maps chunk's `text` / `placeAnswerSources` / `route`. Shared by [EventCodec] and + * [LlmResponseCodec]. + */ +internal object GroundingMetadataCodec { + + /** Returns the Kotlin [KtGroundingMetadata] view of the genai [metadata]. */ + fun fromJava(metadata: GenaiGroundingMetadata): KtGroundingMetadata = + KtGroundingMetadata( + imageSearchQueries = metadata.imageSearchQueries().getOrNull().orEmpty(), + groundingChunks = metadata.groundingChunks().getOrNull()?.map { chunkFromJava(it) }, + groundingSupports = metadata.groundingSupports().getOrNull()?.map { supportFromJava(it) }, + webSearchQueries = metadata.webSearchQueries().getOrNull(), + retrievalQueries = metadata.retrievalQueries().getOrNull(), + searchEntryPoint = + metadata.searchEntryPoint().getOrNull()?.let { searchEntryPointFromJava(it) }, + retrievalMetadata = + metadata.retrievalMetadata().getOrNull()?.let { retrievalMetadataFromJava(it) }, + ) + + /** Returns the genai [GenaiGroundingMetadata] view of the Kotlin [metadata]. */ + fun toJava(metadata: KtGroundingMetadata): GenaiGroundingMetadata { + val builder = GenaiGroundingMetadata.builder().imageSearchQueries(metadata.imageSearchQueries) + metadata.groundingChunks?.let { builder.groundingChunks(it.map { c -> chunkToJava(c) }) } + metadata.groundingSupports?.let { builder.groundingSupports(it.map { s -> supportToJava(s) }) } + metadata.webSearchQueries?.let { builder.webSearchQueries(it) } + metadata.retrievalQueries?.let { builder.retrievalQueries(it) } + metadata.searchEntryPoint?.let { builder.searchEntryPoint(searchEntryPointToJava(it)) } + metadata.retrievalMetadata?.let { builder.retrievalMetadata(retrievalMetadataToJava(it)) } + return builder.build() + } + + private fun chunkFromJava(chunk: GenaiGroundingChunk): KtGroundingChunk = + KtGroundingChunk( + web = chunk.web().getOrNull()?.let { webFromJava(it) }, + retrievedContext = chunk.retrievedContext().getOrNull()?.let { retrievedContextFromJava(it) }, + maps = chunk.maps().getOrNull()?.let { mapsFromJava(it) }, + ) + + private fun chunkToJava(chunk: KtGroundingChunk): GenaiGroundingChunk { + val builder = GenaiGroundingChunk.builder() + chunk.web?.let { builder.web(webToJava(it)) } + chunk.retrievedContext?.let { builder.retrievedContext(retrievedContextToJava(it)) } + chunk.maps?.let { builder.maps(mapsToJava(it)) } + return builder.build() + } + + private fun mapsFromJava(maps: GenaiGroundingChunkMaps): KtGroundingChunkMaps = + KtGroundingChunkMaps( + uri = maps.uri().getOrNull(), + title = maps.title().getOrNull(), + placeId = maps.placeId().getOrNull(), + ) + + private fun mapsToJava(maps: KtGroundingChunkMaps): GenaiGroundingChunkMaps { + val builder = GenaiGroundingChunkMaps.builder() + maps.uri?.let { builder.uri(it) } + maps.title?.let { builder.title(it) } + maps.placeId?.let { builder.placeId(it) } + return builder.build() + } + + private fun webFromJava(web: GenaiGroundingChunkWeb): KtGroundingChunkWeb = + KtGroundingChunkWeb( + uri = web.uri().getOrNull(), + title = web.title().getOrNull(), + domain = web.domain().getOrNull(), + ) + + private fun webToJava(web: KtGroundingChunkWeb): GenaiGroundingChunkWeb { + val builder = GenaiGroundingChunkWeb.builder() + web.uri?.let { builder.uri(it) } + web.title?.let { builder.title(it) } + web.domain?.let { builder.domain(it) } + return builder.build() + } + + private fun retrievedContextFromJava( + context: GenaiGroundingChunkRetrievedContext + ): KtGroundingChunkRetrievedContext = + KtGroundingChunkRetrievedContext( + uri = context.uri().getOrNull(), + title = context.title().getOrNull(), + text = context.text().getOrNull(), + ) + + private fun retrievedContextToJava( + context: KtGroundingChunkRetrievedContext + ): GenaiGroundingChunkRetrievedContext { + val builder = GenaiGroundingChunkRetrievedContext.builder() + context.uri?.let { builder.uri(it) } + context.title?.let { builder.title(it) } + context.text?.let { builder.text(it) } + return builder.build() + } + + private fun supportFromJava(support: GenaiGroundingSupport): KtGroundingSupport = + KtGroundingSupport( + segment = support.segment().getOrNull()?.let { segmentFromJava(it) }, + groundingChunkIndices = support.groundingChunkIndices().getOrNull(), + confidenceScores = support.confidenceScores().getOrNull(), + ) + + private fun supportToJava(support: KtGroundingSupport): GenaiGroundingSupport { + val builder = GenaiGroundingSupport.builder() + support.segment?.let { builder.segment(segmentToJava(it)) } + support.groundingChunkIndices?.let { builder.groundingChunkIndices(it) } + support.confidenceScores?.let { builder.confidenceScores(it) } + return builder.build() + } + + private fun segmentFromJava(segment: GenaiSegment): KtSegment = + KtSegment( + startIndex = segment.startIndex().getOrNull(), + endIndex = segment.endIndex().getOrNull(), + partIndex = segment.partIndex().getOrNull(), + text = segment.text().getOrNull(), + ) + + private fun segmentToJava(segment: KtSegment): GenaiSegment { + val builder = GenaiSegment.builder() + segment.startIndex?.let { builder.startIndex(it) } + segment.endIndex?.let { builder.endIndex(it) } + segment.partIndex?.let { builder.partIndex(it) } + segment.text?.let { builder.text(it) } + return builder.build() + } + + private fun searchEntryPointFromJava(point: GenaiSearchEntryPoint): KtSearchEntryPoint = + KtSearchEntryPoint(renderedContent = point.renderedContent().getOrNull()) + + private fun searchEntryPointToJava(point: KtSearchEntryPoint): GenaiSearchEntryPoint { + val builder = GenaiSearchEntryPoint.builder() + point.renderedContent?.let { builder.renderedContent(it) } + return builder.build() + } + + private fun retrievalMetadataFromJava(metadata: GenaiRetrievalMetadata): KtRetrievalMetadata = + KtRetrievalMetadata( + googleSearchDynamicRetrievalScore = metadata.googleSearchDynamicRetrievalScore().getOrNull() + ) + + private fun retrievalMetadataToJava(metadata: KtRetrievalMetadata): GenaiRetrievalMetadata { + val builder = GenaiRetrievalMetadata.builder() + metadata.googleSearchDynamicRetrievalScore?.let { + builder.googleSearchDynamicRetrievalScore(it) + } + return builder.build() + } +} diff --git a/tokt/src/main/java/com/google/adk/tokt/codecs/KtToJavaCodecs.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/KtToJavaCodecs.kt new file mode 100644 index 000000000..8afdf1b80 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/KtToJavaCodecs.kt @@ -0,0 +1,258 @@ +/* + * 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.codecs + +import com.google.adk.events.Event as JavaEvent +import com.google.adk.events.EventActions as JavaEventActions +import com.google.adk.events.EventCompaction as JavaEventCompaction +import com.google.adk.kt.events.EventActions as KtEventActions +import com.google.adk.kt.sessions.Session as KtSession +import com.google.adk.kt.sessions.SessionKey as KtSessionKey +import com.google.adk.kt.sessions.State as KtState +import com.google.adk.sessions.Session as JavaSession +import com.google.adk.sessions.State as JavaState +import java.util.AbstractMap +import java.util.Optional +import java.util.concurrent.ConcurrentMap +import kotlin.time.toJavaInstant + +/** + * Kt -> Java value conversions (session, instants, and the live event-actions view). These are + * plain value/view conversions shared by both the codecs and the direction-specific adapters, so + * they live in `codecs` rather than `context`. + */ + +/** + * A real Java [JavaEventActions] that delegates live to a Kotlin [KtEventActions]: the delta maps + * and most control-flow signals read and write straight through, so a Java tool mutating + * `actions()` (e.g. `actions().setEscalate(true)`) takes effect on the Kotlin side immediately. + * Escalate returns `Optional.of(false)` when unset (the Kotlin field defaults to false). + * Skip-summarization is left to the base fields (its Java setter has both a boxed and a primitive + * overload that can't both be overridden cleanly) and is reconciled onto the Kotlin side by + * [JavaToolToKt][com.google.adk.tokt.adapters.JavaToolToKt] after the tool runs. A tool that + * instead *replaces* its actions (`setActions(...)`) is likewise reconciled by + * [JavaToolToKt][com.google.adk.tokt.adapters.JavaToolToKt]. For a read-only snapshot, use + * [eventActionsToJava] instead. + */ +internal class KtEventActionsToJavaView(private val actions: KtEventActions) : JavaEventActions() { + override fun stateDelta(): MutableMap = actions.stateDelta + + override fun artifactDelta(): MutableMap = actions.artifactDelta + + override fun transferToAgent(): Optional = Optional.ofNullable(actions.transferToAgent) + + override fun setTransferToAgent(transferToAgent: String?) { + actions.transferToAgent = transferToAgent + } + + override fun escalate(): Optional = Optional.of(actions.escalate) + + override fun setEscalate(escalate: Boolean?) { + actions.escalate = escalate ?: false + } + + override fun endOfAgent(): Boolean = actions.endOfAgent + + override fun setEndOfAgent(endOfAgent: Boolean) { + actions.endOfAgent = endOfAgent + } + + override fun compaction(): Optional = + Optional.ofNullable(actions.compaction?.let { EventCompactionCodec.toJava(it) }) + + override fun setCompaction(compaction: JavaEventCompaction?) { + actions.compaction = compaction?.let { EventCompactionCodec.fromJava(it) } + } + + // The three inherited mutators below write private fields on the base class, which the overridden + // getters do not read, so without these the write is silently lost. + + override fun removeStateByKey(key: String) { + // The Kotlin sentinel directly: this writes into the Kotlin delta, which the engine reads. + actions.stateDelta[key] = KtState.REMOVED + } + + override fun setArtifactDelta(artifactDelta: Map) { + // Refill rather than reassign: the Kotlin map is the live one the engine holds. + actions.artifactDelta.keys.retainAll(artifactDelta.keys) + actions.artifactDelta.putAll(artifactDelta) + } +} + +/** + * Builds a read-only Java [JavaEventActions] snapshot from a Kotlin [KtEventActions], carrying the + * deltas, the control-flow signals (skip-summarization, transfer, escalate, end-of-agent), and the + * requested tool confirmations. Used when exposing an existing Kotlin event to a Java runner + * ([EventCodec.toJava]); unlike [KtEventActionsToJavaView] (a live write sink) nothing writes back + * through it. + * + * Does not carry `agentState` or `rewindBeforeInvocationId`: Java `EventActions` has no equivalent, + * so neither survives a round trip through a Java session service. Both are engine state rather + * than decoration - the first is how a resumable workflow rebuilds its position from history, the + * second drives rewind - so see [com.google.adk.tokt.JavaAdkToKt] for what that costs. + */ +internal fun eventActionsToJava(actions: KtEventActions): JavaEventActions = + JavaEventActions.builder() + .skipSummarization(actions.skipSummarization) + .stateDelta(stateDeltaToJava(actions.stateDelta)) + .artifactDelta(actions.artifactDelta) + .transferToAgent(actions.transferToAgent) + .escalate(actions.escalate) + .requestedToolConfirmations( + actions.requestedToolConfirmations.mapValues { ToolConfirmationCodec.toJava(it.value) } + ) + .endOfAgent(actions.endOfAgent) + .compaction(actions.compaction?.let { EventCompactionCodec.toJava(it) }) + .build() + +/** Copies a Kotlin state delta to Java, mapping the Kotlin [KtState.REMOVED] deletion sentinel. */ +private fun stateDeltaToJava(delta: Map): Map = + delta.mapValues { (_, value) -> + if (value === KtState.REMOVED) JavaState.REMOVED else value + } + +/** + * The Java session id for [key]. A null id is rejected rather than substituted: an empty id would + * silently address a session keyed on "" - matching the session and artifact services, which + * already require it. + */ +internal fun sessionId(key: KtSessionKey): String = + requireNotNull(key.id) { "SessionKey.id must not be null when crossing to ADK Java" } + +/** + * Converts a Kotlin [KtSession] to a Java [JavaSession] **snapshot**: state and events are copied, + * not shared, so later Kotlin-side changes are invisible and writes do not propagate back. Used at + * service boundaries (handing a session to a Java session/memory service), where a point-in-time + * value is what the callee expects. Contexts handed to a running Java tool / plugin / agent use + * [ktSessionToJavaLive] instead. + */ +internal fun ktSessionToJava(session: KtSession): JavaSession = + JavaSession.builder(sessionId(session.key)) + .appName(session.key.appName) + .userId(session.key.userId) + .state(JavaState(session.state)) + .events(session.events.map { EventCodec.toJava(it) }) + .lastUpdateTime(session.lastUpdateTime.toJavaInstant()) + .build() + +/** + * A Java [ConcurrentMap] that reads and writes through live to a Kotlin [KtState]. A Java + * [JavaState] keeps a `ConcurrentMap` backing by reference (it does not copy one), so wrapping this + * in `JavaState(...)` gives a Java session a live state view over the Kotlin session - an adapted + * Java flow then sees state a tool set earlier in the same turn. + * + * Departures from the `ConcurrentMap` contract, none of which ADK relies on: the collection views + * are per-call snapshots of detached entries (`entry.setValue` does not write through); + * [putIfAbsent] / [remove] `(key, value)` / [replace] are read-then-write rather than atomic, as + * [KtState] exposes no compare-and-set (the default `compute`/`merge` inherit this); and [clear] + * also drops the pending state delta. + */ +private class KtStateAsJavaConcurrentMap(private val state: KtState) : ConcurrentMap { + override val size: Int + get() = state.size + + override fun isEmpty(): Boolean = state.isEmpty() + + override fun containsKey(key: String): Boolean = state.containsKey(key) + + override fun containsValue(value: Any): Boolean = state.containsValue(value) + + override fun get(key: String): Any? = state[key] + + override val keys: MutableSet + get() = state.keys.toMutableSet() + + override val values: MutableCollection + get() = state.values.toMutableList() + + override val entries: MutableSet> + get() = state.entries.mapTo(mutableSetOf()) { AbstractMap.SimpleEntry(it.key, it.value) } + + override fun put(key: String, value: Any): Any? = state.set(key, value) + + override fun remove(key: String): Any? = state.remove(key) + + override fun putAll(from: Map) { + state.putAll(from) + } + + override fun clear() { + state.clear() + } + + override fun putIfAbsent(key: String, value: Any): Any? { + val existing = state[key] + if (existing == null) { + state[key] = value + } + return existing + } + + override fun remove(key: String, value: Any): Boolean { + if (state[key] == value) { + state.remove(key) + return true + } + return false + } + + override fun replace(key: String, oldValue: Any, newValue: Any): Boolean { + if (state[key] == oldValue) { + state[key] = newValue + return true + } + return false + } + + override fun replace(key: String, value: Any): Any? = + if (state.containsKey(key)) state.set(key, value) else null +} + +/** + * Like [ktSessionToJava] but backs the returned session with LIVE views: `events()` and `state()` + * convert on access rather than snapshotting. Used for the context/agent bridges, so an adapted + * Java flow (or a Java tool/plugin) always reads a current session - including what the Kotlin + * runner or an earlier step changed this turn - with no re-projection. + * + * `events()` is read-only, the Kotlin runner owning the list, so a Java append throws rather than + * writing to a discarded copy. Each element re-converts on access, deliberately: a Kotlin event's + * `actions` are mutable, so caching would reintroduce the staleness this view removes. Copy the + * list once rather than indexing it in a loop. `lastUpdateTime` is captured here, not live. + */ +// eventsView is deprecated to warn application code off it; an interop adapter is exactly the +// caller it exists for. +@Suppress("DEPRECATION") +internal fun ktSessionToJavaLive(session: KtSession): JavaSession = + JavaSession.builder(sessionId(session.key)) + .appName(session.key.appName) + .userId(session.key.userId) + .state(JavaState(KtStateAsJavaConcurrentMap(session.state))) + .eventsView(KtBackedEventsView(session)) + .lastUpdateTime(session.lastUpdateTime.toJavaInstant()) + .build() + +/** + * The read-only, converting `events()` of [ktSessionToJavaLive]. Named rather than anonymous so a + * caller holding a Java [JavaSession] can tell a live view from a snapshot: this one is already + * backed by the Kotlin session, so it must not be mirrored into (and would throw if tried). + */ +internal class KtBackedEventsView(private val session: KtSession) : AbstractList() { + override val size: Int + get() = session.events.size + + override fun get(index: Int): JavaEvent = EventCodec.toJava(session.events[index]) +} diff --git a/tokt/src/main/java/com/google/adk/tokt/codecs/LlmRequestCodec.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/LlmRequestCodec.kt new file mode 100644 index 000000000..721392a16 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/LlmRequestCodec.kt @@ -0,0 +1,53 @@ +/* + * 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.codecs + +import com.google.adk.kt.models.LlmRequest as KtLlmRequest +import com.google.adk.kt.types.GenerateContentConfig as KtConfig +import com.google.adk.models.LlmRequest as JavaLlmRequest +import kotlin.jvm.optionals.getOrNull + +/** + * Converts a Kotlin `kt.models.LlmRequest` to the ADK Java [JavaLlmRequest] expected by a Java + * `BaseLlm`, so the Kotlin agent loop can call a user's Java model + * ([JavaModelToKt][com.google.adk.tokt.adapters.JavaModelToKt]). + * + * Carries the model name, the request contents, and the [config][GenerateContentConfigCodec] + * (system instruction, tools / function declarations, generation parameters). + */ +internal object LlmRequestCodec { + + /** Returns the Java [JavaLlmRequest] view of the Kotlin [request]. */ + fun toJava(request: KtLlmRequest): JavaLlmRequest { + val builder = + JavaLlmRequest.builder().contents(request.contents.map { ContentCodec.toJava(it) }) + request.model?.let { builder.model(it.name) } + builder.config(GenerateContentConfigCodec.toJava(request.config)) + return builder.build() + } + + /** + * Returns the Kotlin [KtLlmRequest] view of the Java [request]. The model name is left unset; the + * Kotlin model (e.g. [com.google.adk.kt.models.Gemini]) resolves it from its own configuration. + */ + fun fromJava(request: JavaLlmRequest): KtLlmRequest = + KtLlmRequest( + contents = request.contents().map { ContentCodec.fromJava(it) }, + config = + request.config().map { GenerateContentConfigCodec.fromJava(it) }.getOrNull() ?: KtConfig(), + ) +} diff --git a/tokt/src/main/java/com/google/adk/tokt/codecs/LlmResponseCodec.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/LlmResponseCodec.kt new file mode 100644 index 000000000..f5aef6772 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/LlmResponseCodec.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.tokt.codecs + +import com.google.adk.kt.models.LlmResponse as KtLlmResponse +import com.google.adk.kt.types.FinishReason as KtFinishReason +import com.google.adk.models.LlmResponse as JavaLlmResponse +import com.google.genai.types.FinishReason as GenaiFinishReason +import kotlin.jvm.optionals.getOrNull + +/** + * Converts an LLM response between the ADK Java [JavaLlmResponse] and the Kotlin + * `kt.models.LlmResponse` (both directions): the Kotlin agent loop consumes a Java model's response + * ([JavaModelToKt][com.google.adk.tokt.adapters.JavaModelToKt]); Kotlin to Java hands a response to + * a bridged Java plugin's `afterModel`. + * + * Carries content, error message, streaming flags, model version, finish reason, usage metadata, + * grounding metadata, average log-probs, error code, and custom metadata. Citation metadata and + * logprobs results have no ADK Java facade field, so they are not mapped. + */ +internal object LlmResponseCodec { + + /** Returns the Kotlin [KtLlmResponse] view of the Java [response]. */ + fun fromJava(response: JavaLlmResponse): KtLlmResponse = + KtLlmResponse( + content = response.content().getOrNull()?.let { ContentCodec.fromJava(it) }, + errorMessage = response.errorMessage().getOrNull(), + partial = response.partial().getOrNull() ?: false, + interrupted = response.interrupted().getOrNull() ?: false, + modelVersion = response.modelVersion().getOrNull(), + finishReason = + enumByNameOrNull(response.finishReason().getOrNull()?.knownEnum()?.name), + usageMetadata = response.usageMetadata().getOrNull()?.let { UsageMetadataCodec.fromJava(it) }, + groundingMetadata = + response.groundingMetadata().getOrNull()?.let { GroundingMetadataCodec.fromJava(it) }, + avgLogprobs = response.avgLogprobs().getOrNull(), + // toString() preserves the raw value; knownEnum().name would collapse an unrecognized code to + // FINISH_REASON_UNSPECIFIED (errorCode is a free-form String on the Kotlin side). + errorCode = response.errorCode().getOrNull()?.toString(), + customMetadata = + response.customMetadata().getOrNull()?.let { CustomMetadataCodec.fromJava(it) }, + ) + + /** Returns the Java [JavaLlmResponse] view of the Kotlin [response]. */ + fun toJava(response: KtLlmResponse): JavaLlmResponse { + val builder = + JavaLlmResponse.builder().partial(response.partial).interrupted(response.interrupted) + response.content?.let { builder.content(ContentCodec.toJava(it)) } + response.errorMessage?.let { builder.errorMessage(it) } + response.modelVersion?.let { builder.modelVersion(it) } + response.finishReason?.let { builder.finishReason(it.toGenai()) } + response.usageMetadata?.let { builder.usageMetadata(UsageMetadataCodec.toJava(it)) } + response.groundingMetadata?.let { builder.groundingMetadata(GroundingMetadataCodec.toJava(it)) } + response.avgLogprobs?.let { builder.avgLogprobs(it) } + response.errorCode?.let { builder.errorCode(GenaiFinishReason(it)) } + response.customMetadata?.let { builder.customMetadata(CustomMetadataCodec.toJava(it)) } + return builder.build() + } +} diff --git a/tokt/src/main/java/com/google/adk/tokt/codecs/MemoryEntryCodec.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/MemoryEntryCodec.kt new file mode 100644 index 000000000..89bfdbc0d --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/MemoryEntryCodec.kt @@ -0,0 +1,53 @@ +/* + * 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.codecs + +import com.google.adk.kt.memory.MemoryEntry as KtMemoryEntry +import com.google.adk.kt.memory.SearchMemoryResponse as KtSearchMemoryResponse +import com.google.adk.memory.MemoryEntry as JavaMemoryEntry +import com.google.adk.memory.SearchMemoryResponse as JavaSearchMemoryResponse + +/** + * Converts the ADK Kotlin's memory search results ([KtMemoryEntry], [KtSearchMemoryResponse]) to + * the ADK Java facade types. + */ +internal object MemoryEntryCodec { + + /** Returns the Java [JavaMemoryEntry] view of the Kotlin [entry]. */ + fun toJava(entry: KtMemoryEntry): JavaMemoryEntry = + JavaMemoryEntry.builder() + .content(ContentCodec.toJava(entry.content)) + .author(entry.author) + .timestamp(entry.timestamp) + .build() + + /** Returns the Java [JavaSearchMemoryResponse] view of the Kotlin [response]. */ + fun toJava(response: KtSearchMemoryResponse): JavaSearchMemoryResponse = + JavaSearchMemoryResponse.builder().memories(response.memories.map { toJava(it) }).build() + + /** Returns the Kotlin [KtMemoryEntry] view of the Java [entry]. */ + fun fromJava(entry: JavaMemoryEntry): KtMemoryEntry = + KtMemoryEntry( + content = ContentCodec.fromJava(entry.content()), + author = entry.author(), + timestamp = entry.timestamp(), + ) + + /** Returns the Kotlin [KtSearchMemoryResponse] view of the Java [response]. */ + fun fromJava(response: JavaSearchMemoryResponse): KtSearchMemoryResponse = + KtSearchMemoryResponse(memories = response.memories().map { fromJava(it) }) +} diff --git a/tokt/src/main/java/com/google/adk/tokt/codecs/PartCodec.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/PartCodec.kt new file mode 100644 index 000000000..33a045a2d --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/PartCodec.kt @@ -0,0 +1,255 @@ +/* + * 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.codecs + +import com.google.adk.kt.logging.LoggerFactory +import com.google.adk.kt.types.Blob as KtBlob +import com.google.adk.kt.types.FileData as KtFileData +import com.google.adk.kt.types.FunctionCall as KtFunctionCall +import com.google.adk.kt.types.FunctionResponse as KtFunctionResponse +import com.google.adk.kt.types.Part as KtPart +import com.google.adk.kt.types.PartialArg as KtPartialArg +import com.google.adk.kt.types.PartialArgValue as KtPartialArgValue +import com.google.adk.kt.types.VideoMetadata as KtVideoMetadata +import com.google.genai.types.Blob as GenaiBlob +import com.google.genai.types.FileData as GenaiFileData +import com.google.genai.types.FunctionCall as GenaiFunctionCall +import com.google.genai.types.FunctionResponse as GenaiFunctionResponse +import com.google.genai.types.NullValue as GenaiNullValue +import com.google.genai.types.Part as GenaiPart +import com.google.genai.types.PartialArg as GenaiPartialArg +import com.google.genai.types.VideoMetadata as GenaiVideoMetadata +import java.util.concurrent.ConcurrentHashMap +import kotlin.jvm.optionals.getOrNull +import kotlin.time.toJavaDuration +import kotlin.time.toKotlinDuration + +/** + * Converts a [Part][KtPart] between the genai type ADK Java exposes and the ADK Kotlin type. + * Carries text, inline binary data ([KtBlob]), file references ([KtFileData]), function + * call/response parts (each preserving its name, id, and arguments; the inline byte array is shared + * by reference), the model "thought" marker/signature, video metadata, and part metadata. Shared by + * [ContentCodec] and the artifact service. + * + * Returns null for an empty part and for one carrying only a field the Kotlin [KtPart] has no + * counterpart for - `executableCode`, `codeExecutionResult`, `toolCall`, `toolResponse` - which + * [ContentCodec] then drops from the content. The loss is forced, so it is logged once per kind + * rather than silent; see [com.google.adk.tokt.JavaAdkToKt]. + */ +internal object PartCodec { + + private val logger = LoggerFactory.getLogger(PartCodec::class) + + /** Kinds already reported: a code-execution turn emits many parts, and one warning is enough. */ + private val warnedUnmappedKinds = ConcurrentHashMap.newKeySet() + + private fun warnUnmappedKind(part: GenaiPart) { + val kind = + when { + part.executableCode().isPresent -> "executableCode" + part.codeExecutionResult().isPresent -> "codeExecutionResult" + part.toolCall().isPresent -> "toolCall" + part.toolResponse().isPresent -> "toolResponse" + else -> return // A genuinely empty part carries no information to lose. + } + if (warnedUnmappedKinds.add(kind)) { + logger.warn { + "Dropping a genai Part that carries only $kind: the ADK Kotlin Part has no equivalent, so" + + " it cannot cross the Java -> Kotlin interop and will be missing from the event stream." + } + } + } + + /** + * As [fromJava], but rejects a part that carries nothing mapped. The artifact services must not + * silently persist or return an empty artifact. + */ + fun fromJavaOrThrow(part: GenaiPart): KtPart = + requireNotNull(fromJava(part)) { "Artifact part is empty or unmapped" } + + /** As [toJava], rejecting a part that carries nothing; see [fromJavaOrThrow]. */ + fun toJavaOrThrow(part: KtPart): GenaiPart = + requireNotNull(toJava(part)) { "Artifact part is empty or unmapped" } + + /** Returns the Kotlin [KtPart] view of the genai [part], or null if it carries nothing mapped. */ + fun fromJava(part: GenaiPart): KtPart? { + val functionCall = part.functionCall().getOrNull() + val functionResponse = part.functionResponse().getOrNull() + val inlineData = part.inlineData().getOrNull() + val fileData = part.fileData().getOrNull() + val text = part.text().getOrNull() + val thought = part.thought().getOrNull() + val thoughtSignature = part.thoughtSignature().getOrNull() + val partMetadata = part.partMetadata().getOrNull() + val videoMetadata = part.videoMetadata().getOrNull()?.let { videoMetadataFromJava(it) } + val base = + when { + functionCall != null -> KtPart(functionCall = functionCallFromJava(functionCall)) + functionResponse != null -> + KtPart(functionResponse = functionResponseFromJava(functionResponse)) + inlineData != null -> KtPart(inlineData = blobFromJava(inlineData)) + fileData != null -> KtPart(fileData = fileDataFromJava(fileData)) + text != null -> KtPart(text = text) + // A part carrying only a thought/thoughtSignature or metadata (no primary payload) is still + // meaningful - dropping it breaks Gemini thinking continuity - so keep it. + thought != null || + thoughtSignature != null || + partMetadata != null || + videoMetadata != null -> KtPart() + else -> { + warnUnmappedKind(part) + return null + } + } + return base.copy( + thought = thought, + thoughtSignature = thoughtSignature, + partMetadata = partMetadata, + videoMetadata = videoMetadata, + ) + } + + /** Returns the genai [GenaiPart] view of the Kotlin [part], or null if it carries nothing. */ + fun toJava(part: KtPart): GenaiPart? { + val functionCall = part.functionCall + val functionResponse = part.functionResponse + val inlineData = part.inlineData + val fileData = part.fileData + val text = part.text + val builder = + when { + functionCall != null -> GenaiPart.builder().functionCall(functionCallToJava(functionCall)) + functionResponse != null -> + GenaiPart.builder().functionResponse(functionResponseToJava(functionResponse)) + inlineData != null -> GenaiPart.builder().inlineData(blobToJava(inlineData)) + fileData != null -> GenaiPart.builder().fileData(fileDataToJava(fileData)) + text != null -> GenaiPart.builder().text(text) + // Keep a thought/thoughtSignature- or metadata-only part (no primary payload); dropping it + // breaks Gemini thinking continuity. + part.thought != null || + part.thoughtSignature != null || + part.partMetadata != null || + part.videoMetadata != null -> GenaiPart.builder() + else -> return null + } + part.thought?.let { builder.thought(it) } + part.thoughtSignature?.let { builder.thoughtSignature(it) } + part.partMetadata?.let { builder.partMetadata(it) } + part.videoMetadata?.let { builder.videoMetadata(videoMetadataToJava(it)) } + return builder.build() + } + + private fun videoMetadataFromJava(metadata: GenaiVideoMetadata): KtVideoMetadata = + KtVideoMetadata( + startOffset = metadata.startOffset().getOrNull()?.toKotlinDuration(), + endOffset = metadata.endOffset().getOrNull()?.toKotlinDuration(), + fps = metadata.fps().getOrNull(), + ) + + private fun videoMetadataToJava(metadata: KtVideoMetadata): GenaiVideoMetadata { + val builder = GenaiVideoMetadata.builder() + metadata.startOffset?.let { builder.startOffset(it.toJavaDuration()) } + metadata.endOffset?.let { builder.endOffset(it.toJavaDuration()) } + metadata.fps?.let { builder.fps(it) } + return builder.build() + } + + private fun blobFromJava(blob: GenaiBlob): KtBlob = + KtBlob( + mimeType = blob.mimeType().getOrNull(), + displayName = blob.displayName().getOrNull(), + data = blob.data().getOrNull(), + ) + + private fun blobToJava(blob: KtBlob): GenaiBlob { + val builder = GenaiBlob.builder() + blob.data?.let { builder.data(it) } + blob.mimeType?.let { builder.mimeType(it) } + blob.displayName?.let { builder.displayName(it) } + return builder.build() + } + + private fun fileDataFromJava(fileData: GenaiFileData): KtFileData = + KtFileData( + mimeType = fileData.mimeType().getOrNull(), + displayName = fileData.displayName().getOrNull(), + fileUri = fileData.fileUri().getOrNull(), + ) + + private fun fileDataToJava(fileData: KtFileData): GenaiFileData { + val builder = GenaiFileData.builder() + fileData.fileUri?.let { builder.fileUri(it) } + fileData.mimeType?.let { builder.mimeType(it) } + fileData.displayName?.let { builder.displayName(it) } + return builder.build() + } + + private fun functionCallFromJava(call: GenaiFunctionCall): KtFunctionCall = + KtFunctionCall( + name = call.name().getOrNull() ?: "", + args = call.args().getOrNull().orEmpty(), + id = call.id().getOrNull(), + partialArgs = call.partialArgs().getOrNull()?.map { partialArgFromJava(it) }, + willContinue = call.willContinue().getOrNull(), + ) + + private fun functionCallToJava(call: KtFunctionCall): GenaiFunctionCall { + val builder = GenaiFunctionCall.builder().name(call.name).args(call.args) + call.id?.let { builder.id(it) } + call.partialArgs?.let { builder.partialArgs(it.map { arg -> partialArgToJava(arg) }) } + call.willContinue?.let { builder.willContinue(it) } + return builder.build() + } + + private fun partialArgFromJava(arg: GenaiPartialArg): KtPartialArg = + KtPartialArg( + value = + arg.boolValue().getOrNull()?.let { KtPartialArgValue.BoolValue(it) } + ?: arg.numberValue().getOrNull()?.let { KtPartialArgValue.NumberValue(it) } + ?: arg.stringValue().getOrNull()?.let { KtPartialArgValue.StringValue(it) } + ?: arg.nullValue().getOrNull()?.let { KtPartialArgValue.NullValue }, + jsonPath = arg.jsonPath().getOrNull(), + willContinue = arg.willContinue().getOrNull(), + ) + + private fun partialArgToJava(arg: KtPartialArg): GenaiPartialArg { + val builder = GenaiPartialArg.builder() + when (val value = arg.value) { + is KtPartialArgValue.BoolValue -> builder.boolValue(value.value) + is KtPartialArgValue.NumberValue -> builder.numberValue(value.value) + is KtPartialArgValue.StringValue -> builder.stringValue(value.value) + is KtPartialArgValue.NullValue -> builder.nullValue(GenaiNullValue.Known.NULL_VALUE) + null -> {} + } + arg.jsonPath?.let { builder.jsonPath(it) } + arg.willContinue?.let { builder.willContinue(it) } + return builder.build() + } + + private fun functionResponseFromJava(response: GenaiFunctionResponse): KtFunctionResponse = + KtFunctionResponse( + name = response.name().getOrNull() ?: "", + response = response.response().getOrNull().orEmpty(), + id = response.id().getOrNull(), + ) + + private fun functionResponseToJava(response: KtFunctionResponse): GenaiFunctionResponse { + val builder = GenaiFunctionResponse.builder().name(response.name).response(response.response) + response.id?.let { builder.id(it) } + return builder.build() + } +} diff --git a/tokt/src/main/java/com/google/adk/tokt/codecs/RunConfigCodec.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/RunConfigCodec.kt new file mode 100644 index 000000000..b27c5c520 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/RunConfigCodec.kt @@ -0,0 +1,45 @@ +/* + * 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.codecs + +import com.google.adk.agents.RunConfig as JavaRunConfig +import com.google.adk.kt.agents.RunConfig as KtRunConfig + +/** + * Converts the Kotlin `kt.agents.RunConfig` to the ADK Java [JavaRunConfig] a bridged Java + * component reads through its invocation context. + * + * 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. + */ +internal object RunConfigCodec { + + /** Returns the Java [JavaRunConfig] view of the Kotlin [config]. */ + fun toJava(config: KtRunConfig): JavaRunConfig = + JavaRunConfig.builder() + // Kotlin has no BIDI, so a by-name match always resolves; NONE is the shared default. + .streamingMode( + enumByNameOrNull(config.streamingMode.name) + ?: JavaRunConfig.StreamingMode.NONE + ) + .maxLlmCalls(config.maxLlmCalls) + .customMetadata(config.customMetadata.orEmpty()) + .build() +} diff --git a/tokt/src/main/java/com/google/adk/tokt/codecs/SchemaCodec.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/SchemaCodec.kt new file mode 100644 index 000000000..bc2a321ee --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/SchemaCodec.kt @@ -0,0 +1,56 @@ +/* + * 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.codecs + +import com.google.adk.kt.types.Schema as KtSchema +import com.google.adk.kt.types.Type as KtType +import com.google.genai.types.Schema as GenaiSchema +import kotlin.jvm.optionals.getOrNull + +/** + * Converts an OpenAPI [Schema][KtSchema] between the genai type the ADK Java facade exposes and the + * Kotlin's `kt.types.Schema`, in both directions. + * + * The (recursive) schema is carried with its type, properties, items, required, description, and + * enum; the OpenAPI `Type` is mapped by name (the genai and Kotlin enums share the same names). + * Schema facets outside that subset (format, nullable, ...) are not carried yet. + */ +internal object SchemaCodec { + + /** Returns the Kotlin [KtSchema] view of the genai [schema]. */ + fun fromJava(schema: GenaiSchema): KtSchema = + KtSchema( + type = enumByNameOrNull(schema.type().getOrNull()?.knownEnum()?.name), + properties = schema.properties().getOrNull()?.mapValues { fromJava(it.value) }, + items = schema.items().getOrNull()?.let { fromJava(it) }, + required = schema.required().getOrNull(), + description = schema.description().getOrNull(), + enum = schema.enum_().getOrNull(), + ) + + /** Returns the genai [GenaiSchema] view of the Kotlin [schema]. */ + fun toJava(schema: KtSchema): GenaiSchema { + val builder = GenaiSchema.builder() + schema.type?.let { builder.type(it.name) } + schema.properties?.let { props -> builder.properties(props.mapValues { toJava(it.value) }) } + schema.items?.let { builder.items(toJava(it)) } + schema.required?.let { builder.required(it) } + schema.description?.let { builder.description(it) } + schema.enum?.let { builder.enum_(it) } + return builder.build() + } +} diff --git a/tokt/src/main/java/com/google/adk/tokt/codecs/SessionCodec.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/SessionCodec.kt new file mode 100644 index 000000000..5bf87976a --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/SessionCodec.kt @@ -0,0 +1,36 @@ +/* + * 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.codecs + +import com.google.adk.kt.sessions.Session as KtSession +import com.google.adk.kt.sessions.SessionKey +import com.google.adk.kt.sessions.State +import com.google.adk.sessions.Session as JavaSession +import kotlin.time.toKotlinInstant + +/** Converts a Java session (key, state, and event history) to the Kotlin [KtSession]. */ +internal object SessionCodec { + + fun fromJava(session: JavaSession): KtSession = + KtSession( + key = SessionKey(appName = session.appName(), userId = session.userId(), id = session.id()), + // Translate the Java removal sentinel so no foreign sentinel leaks into the Kotlin state. + state = State(initialState = session.state().mapValues { stateValueFromJava(it.value) }), + events = session.events().map { EventCodec.fromJava(it) }.toMutableList(), + lastUpdateTime = session.lastUpdateTime().toKotlinInstant(), + ) +} diff --git a/tokt/src/main/java/com/google/adk/tokt/codecs/StateDeltaCodec.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/StateDeltaCodec.kt new file mode 100644 index 000000000..99499b2c3 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/StateDeltaCodec.kt @@ -0,0 +1,52 @@ +/* + * 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.codecs + +import com.google.adk.kt.sessions.State as KtState +import com.google.adk.sessions.State as JavaState + +/** + * Translates the removed-entry sentinel between the ADK Java and Kotlin `State`. The two frameworks + * use distinct singleton sentinels ([JavaState.REMOVED] / [KtState.REMOVED]) and the Kotlin engine + * matches deletions by identity, so a Java sentinel left in a Kotlin delta would be persisted as a + * value instead of removing the key. + */ + +/** + * Maps a Java state value to Kotlin, converting the removal sentinel; other values pass through. + */ +internal fun stateValueFromJava(value: Any): Any = + if (value === JavaState.REMOVED) KtState.REMOVED else value + +/** Copies a Java state delta to a new Kotlin delta, translating the removal sentinel. */ +internal fun stateDeltaFromJava(delta: Map?): MutableMap { + val result = mutableMapOf() + delta?.forEach { (key, value) -> result[key] = stateValueFromJava(value) } + return result +} + +/** + * Translates any Java removal sentinel to the Kotlin one in a live Kotlin [delta], in place. A Java + * tool / plugin removing a key through the live actions view writes the Java sentinel straight into + * the Kotlin delta map (both are backed by the same concurrent map); this reconciles it so the + * engine recognizes the deletion. + */ +internal fun reconcileRemovedSentinels(delta: MutableMap) { + for (entry in delta.entries) { + if (entry.value === JavaState.REMOVED) entry.setValue(KtState.REMOVED) + } +} diff --git a/tokt/src/main/java/com/google/adk/tokt/codecs/ToolConfirmationCodec.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/ToolConfirmationCodec.kt new file mode 100644 index 000000000..208fe4860 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/ToolConfirmationCodec.kt @@ -0,0 +1,44 @@ +/* + * 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.codecs + +import com.google.adk.events.ToolConfirmation as JavaToolConfirmation +import com.google.adk.kt.events.ToolConfirmation as KtToolConfirmation + +/** + * Converts a [ToolConfirmation][JavaToolConfirmation] between the ADK Java facade and the ADK + * Kotlin, so a Java tool's `toolContext.requestConfirmation(...)` request reaches the Kotlin + * confirmation flow. + */ +internal object ToolConfirmationCodec { + + /** Returns the Java [JavaToolConfirmation] view of the Kotlin [confirmation]. */ + fun toJava(confirmation: KtToolConfirmation): JavaToolConfirmation = + JavaToolConfirmation.builder() + .confirmed(confirmation.confirmed) + .hint(confirmation.hint) + .payload(confirmation.payload) + .build() + + /** Returns the Kotlin [KtToolConfirmation] view of the Java [confirmation]. */ + fun fromJava(confirmation: JavaToolConfirmation): KtToolConfirmation = + KtToolConfirmation( + confirmed = confirmation.confirmed(), + payload = confirmation.payload(), + hint = confirmation.hint(), + ) +} diff --git a/tokt/src/main/java/com/google/adk/tokt/codecs/UsageMetadataCodec.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/UsageMetadataCodec.kt new file mode 100644 index 000000000..eeba31fb0 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/UsageMetadataCodec.kt @@ -0,0 +1,88 @@ +/* + * 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.codecs + +import com.google.adk.kt.types.MediaModality as KtMediaModality +import com.google.adk.kt.types.ModalityTokenCount as KtModalityTokenCount +import com.google.adk.kt.types.UsageMetadata as KtUsageMetadata +import com.google.genai.types.GenerateContentResponseUsageMetadata as GenaiUsageMetadata +import com.google.genai.types.MediaModality as GenaiMediaModality +import com.google.genai.types.ModalityTokenCount as GenaiModalityTokenCount +import com.google.genai.types.TrafficType as GenaiTrafficType +import kotlin.jvm.optionals.getOrNull + +/** + * Converts token-usage metadata between the genai + * [GenerateContentResponseUsageMetadata][GenaiUsageMetadata] the ADK Java facade exposes and the + * Kotlin `kt.types.UsageMetadata`. Carries the prompt/candidates/total/thoughts/tool-use/cached + * token counts, the per-modality token breakdowns, and the traffic type. Shared by + * [LlmResponseCodec] and [EventCodec]. + */ +internal object UsageMetadataCodec { + + /** Returns the Kotlin [KtUsageMetadata] view of the genai [usage]. */ + fun fromJava(usage: GenaiUsageMetadata): KtUsageMetadata = + KtUsageMetadata( + promptTokenCount = usage.promptTokenCount().getOrNull(), + candidatesTokenCount = usage.candidatesTokenCount().getOrNull(), + totalTokenCount = usage.totalTokenCount().getOrNull(), + thoughtsTokenCount = usage.thoughtsTokenCount().getOrNull(), + toolUsePromptTokenCount = usage.toolUsePromptTokenCount().getOrNull(), + cachedContentTokenCount = usage.cachedContentTokenCount().getOrNull(), + promptTokensDetails = usage.promptTokensDetails().getOrNull()?.map { modalityFromJava(it) }, + candidatesTokensDetails = + usage.candidatesTokensDetails().getOrNull()?.map { modalityFromJava(it) }, + toolUsePromptTokensDetails = + usage.toolUsePromptTokensDetails().getOrNull()?.map { modalityFromJava(it) }, + trafficType = usage.trafficType().getOrNull()?.knownEnum()?.name, + ) + + /** Returns the genai [GenaiUsageMetadata] view of the Kotlin [usage]. */ + fun toJava(usage: KtUsageMetadata): GenaiUsageMetadata { + val builder = GenaiUsageMetadata.builder() + usage.promptTokenCount?.let { builder.promptTokenCount(it) } + usage.candidatesTokenCount?.let { builder.candidatesTokenCount(it) } + usage.totalTokenCount?.let { builder.totalTokenCount(it) } + usage.thoughtsTokenCount?.let { builder.thoughtsTokenCount(it) } + usage.toolUsePromptTokenCount?.let { builder.toolUsePromptTokenCount(it) } + usage.cachedContentTokenCount?.let { builder.cachedContentTokenCount(it) } + usage.promptTokensDetails?.let { + builder.promptTokensDetails(it.map { m -> modalityToJava(m) }) + } + usage.candidatesTokensDetails?.let { + builder.candidatesTokensDetails(it.map { m -> modalityToJava(m) }) + } + usage.toolUsePromptTokensDetails?.let { + builder.toolUsePromptTokensDetails(it.map { m -> modalityToJava(m) }) + } + usage.trafficType?.let { builder.trafficType(GenaiTrafficType(it)) } + return builder.build() + } + + private fun modalityFromJava(count: GenaiModalityTokenCount): KtModalityTokenCount = + KtModalityTokenCount( + modality = enumByNameOrNull(count.modality().getOrNull()?.knownEnum()?.name), + tokenCount = count.tokenCount().getOrNull(), + ) + + private fun modalityToJava(count: KtModalityTokenCount): GenaiModalityTokenCount { + val builder = GenaiModalityTokenCount.builder() + count.modality?.let { builder.modality(GenaiMediaModality(it.name)) } + count.tokenCount?.let { builder.tokenCount(it) } + return builder.build() + } +} diff --git a/tokt/src/main/java/com/google/adk/tokt/context/KtCallbackContextToJava.kt b/tokt/src/main/java/com/google/adk/tokt/context/KtCallbackContextToJava.kt new file mode 100644 index 000000000..2026284ab --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/context/KtCallbackContextToJava.kt @@ -0,0 +1,90 @@ +/* + * 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.context + +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.kt.agents.CallbackContext as KtCallbackContext +import com.google.adk.kt.annotations.FrameworkInternalApi +import com.google.adk.sessions.BaseSessionService as JavaBaseSessionService +import com.google.adk.tokt.codecs.ContentCodec +import com.google.adk.tokt.codecs.KtEventActionsToJavaView +import com.google.adk.tokt.codecs.ktSessionToJavaLive +import com.google.adk.tokt.services.ktArtifactServiceAsJava +import com.google.adk.tokt.services.ktMemoryServiceAsJava +import java.util.Optional + +/** + * A Java [JavaInvocationContext] backed by a Kotlin [KtCallbackContext]'s readonly view. The + * session (set on the builder) is a live view of the current Kotlin session; the artifact / memory + * services and user content the callback context exposes are wired through so a Java plugin + * callback can load / save artifacts and add to memory. + */ +private class KtCallbackContextToJavaView( + private val context: KtCallbackContext, + private val agent: JavaBaseAgent, +) : JavaInvocationContext(callbackContextJavaBuilder(context)) { + + override fun invocationId(): String = context.invocationId + + override fun branch(): Optional = Optional.ofNullable(context.branch) + + override fun agent(): JavaBaseAgent = agent + + override fun sessionService(): JavaBaseSessionService = + throw UnsupportedOperationException( + "A Kotlin callback context does not expose its session service, so it is unavailable to a " + + "bridged Java plugin callback" + ) + + @OptIn(FrameworkInternalApi::class) + override fun callbackContextData(): MutableMap = context.callbackContextData +} + +/** + * Builds the base builder for [KtCallbackContextToJavaView], wiring the artifact / memory services + * (unwrapped where possible) and user content the readonly [KtCallbackContext] exposes. A Kotlin + * callback context does not expose its session service, so the view throws for it rather than + * leaving the field null: this builder bypasses `build()`, so `validate()` never runs and the unset + * field would otherwise surface as an NPE at the plugin's own call site. + */ +private fun callbackContextJavaBuilder(context: KtCallbackContext): JavaInvocationContext.Builder { + val builder = + JavaInvocationContext.builder() + .invocationId(context.invocationId) + // Live view: events + state read through to the current Kotlin session. + .session(ktSessionToJavaLive(context.session)) + context.artifactService?.let { builder.artifactService(ktArtifactServiceAsJava(it)) } + context.memoryService?.let { builder.memoryService(ktMemoryServiceAsJava(it)) } + context.userContent?.let { builder.userContent(ContentCodec.toJava(it)) } + return builder +} + +/** + * Presents the Kotlin [context] as a Java [JavaCallbackContext] (reusing [KtEventActionsToJavaView] + * so a callback's state / artifact deltas write straight through to the Kotlin context). [agent] is + * the Java agent the callback belongs to, so a callback sees the correct `context.agent()`. + */ +internal fun ktCallbackContextToJava( + context: KtCallbackContext, + agent: JavaBaseAgent, +): JavaCallbackContext = + JavaCallbackContext( + KtCallbackContextToJavaView(context, agent), + KtEventActionsToJavaView(context.eventActions), + ) diff --git a/tokt/src/main/java/com/google/adk/tokt/context/KtToJava.kt b/tokt/src/main/java/com/google/adk/tokt/context/KtToJava.kt new file mode 100644 index 000000000..4fcb17d3e --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/context/KtToJava.kt @@ -0,0 +1,194 @@ +/* + * 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.context + +import com.google.adk.agents.BaseAgent as JavaBaseAgent +import com.google.adk.agents.InvocationContext as JavaInvocationContext +import com.google.adk.agents.ReadonlyContext as JavaReadonlyContext +import com.google.adk.events.Event as JavaEvent +import com.google.adk.kt.agents.BaseAgent as KtBaseAgent +import com.google.adk.kt.agents.CallbackContext as KtCallbackContext +import com.google.adk.kt.agents.InvocationContext as KtInvocationContext +import com.google.adk.kt.agents.ReadonlyContext as KtReadonlyContext +import com.google.adk.kt.annotations.FrameworkInternalApi +import com.google.adk.kt.tools.ToolContext as KtToolContext +import com.google.adk.tokt.adapters.ktAgentAsJava +import com.google.adk.tokt.codecs.ContentCodec +import com.google.adk.tokt.codecs.EventCodec +import com.google.adk.tokt.codecs.KtEventActionsToJavaView +import com.google.adk.tokt.codecs.RunConfigCodec +import com.google.adk.tokt.codecs.ToolConfirmationCodec +import com.google.adk.tokt.codecs.ktSessionToJavaLive +import com.google.adk.tokt.codecs.sessionId +import com.google.adk.tokt.services.ktArtifactServiceAsJava +import com.google.adk.tokt.services.ktMemoryServiceAsJava +import com.google.adk.tokt.services.ktSessionServiceAsJava +import com.google.adk.tools.ToolContext as JavaToolContext +import com.google.genai.types.Content as GenaiContent +import java.util.Collections +import java.util.Optional +import java.util.concurrent.ConcurrentMap + +/** + * Kt -> Java context views used when the ADK Kotlin calls back into ADK Java code (a Java tool) or + * returns results. These subclass the Java ADK context types and delegate straight to the Kotlin + * context - no neutral interfaces, no identity registry. (Plain value conversions live in + * `codecs`.) + */ + +/** + * A real Java [JavaReadonlyContext] backed by a Kotlin [KtReadonlyContext], used when the engine + * pulls tools from a Java toolset + * ([JavaToolsetToKt][com.google.adk.tokt.adapters.JavaToolsetToKt]). Every accessor reads through + * to the Kotlin context; [invocationContext] is not available (the Kotlin exposes only the + * read-only view). + */ +internal class KtReadonlyContextToJavaView(private val ctx: KtReadonlyContext) : + JavaReadonlyContext(null) { + + override fun userContent(): Optional = + Optional.ofNullable(ctx.userContent?.let { ContentCodec.toJava(it) }) + + override fun invocationId(): String = ctx.invocationId + + override fun branch(): Optional = Optional.ofNullable(ctx.branch) + + override fun agentName(): String = ctx.agentName + + override fun userId(): String = ctx.userId + + override fun sessionId(): String = sessionId(ctx.session.key) + + override fun events(): List = + Collections.unmodifiableList(ctx.session.events.map { EventCodec.toJava(it) }) + + override fun state(): Map = Collections.unmodifiableMap(ctx.state) + + override fun invocationContext(): JavaInvocationContext = + throw UnsupportedOperationException( + "ReadonlyContext.invocationContext() is unavailable for a Java toolset on the Kotlin engine" + ) +} + +/** + * The Java view of [agent], memoized in the invocation's scratch data. [ktAgentAsJava] wraps and + * re-validates the whole sub-agent tree, and Java agents compare by identity, so a plugin that + * pairs its before/after callbacks by agent must be handed the same instance each time. + */ +private fun javaAgentView( + agent: KtBaseAgent, + callbackContextData: MutableMap, +): JavaBaseAgent = + // computeIfAbsent, not getOrPut: the engine runs a turn's function calls concurrently, and + // getOrPut is get-then-put with no CAS, so two racing callers would each get their own wrapper - + // the very identity split this memo exists to prevent. The field is declared MutableMap, so fall + // back to a monitor when it is not concurrent rather than casting blind. + memoize(callbackContextData, "tokt.javaAgent.${agent.name}") { ktAgentAsJava(agent) } + as JavaBaseAgent + +/** Computes [key] at most once in [data], atomically when the map supports it. */ +private fun memoize(data: MutableMap, key: String, compute: () -> Any): Any = + when (data) { + is ConcurrentMap -> data.computeIfAbsent(key) { compute() } + else -> synchronized(data) { data.getOrPut(key, compute) } + } + +/** The memoized Java view of the agent a callback context belongs to. */ +@OptIn(FrameworkInternalApi::class) +internal fun javaAgentView(context: KtCallbackContext): JavaBaseAgent = + javaAgentView(context.agent, context.callbackContextData) + +/** The memoized Java view of the agent an invocation is running. */ +@OptIn(FrameworkInternalApi::class) +internal fun javaAgentView(ic: KtInvocationContext): JavaBaseAgent = + javaAgentView(ic.agent, ic.frameworkData.callbackContextData) + +/** + * Builds the base builder for [KtInvocationContextToJavaView], wiring the Kotlin services as Java + * views (unwrapped to the original Java service where possible to avoid extra hops). + */ +@OptIn(FrameworkInternalApi::class) +private fun ktInvocationContextJavaBuilder(ic: KtInvocationContext): JavaInvocationContext.Builder { + val builder = + JavaInvocationContext.builder() + .invocationId(ic.invocationId) + // Live view: events + state read through, so it never needs re-projecting. + .session(ktSessionToJavaLive(ic.session)) + // Wire the agent (as an inspection-only view) so a Java tool reading ToolContext.agentName() + // / invocationContext.agent() does not hit a null. + .agent(javaAgentView(ic.agent, ic.frameworkData.callbackContextData)) + // Without this the Java view reports a default RunConfig, so a Java component would read the + // wrong streaming mode and call budget rather than the ones the run was started with. + ic.runConfig?.let { builder.runConfig(RunConfigCodec.toJava(it)) } + ic.sessionService?.let { builder.sessionService(ktSessionServiceAsJava(it)) } + ic.artifactService?.let { builder.artifactService(ktArtifactServiceAsJava(it)) } + ic.memoryService?.let { builder.memoryService(ktMemoryServiceAsJava(it)) } + // ReadonlyContext.userContent() delegates here, so a Java tool and a Java plugin callback must + // both see the turn's content. + ic.userContent?.let { builder.userContent(ContentCodec.toJava(it)) } + return builder +} + +/** + * A real Java [JavaInvocationContext] backed live by a Kotlin [KtInvocationContext]. It subclasses + * the Java type (so `instanceof`/casts keep working) and overrides the mutable accessors to read + * and write through to the Kotlin context. The builder-set session is a live view of the current + * Kotlin session; the session / artifact / memory services are wired through too. + * + * The single Java view of a Kotlin invocation: tools ([ktToolContextToJava]) and plugin run-level + * callbacks share it, so a Java component sees the same context wherever it runs. + */ +internal class KtInvocationContextToJavaView(private val ic: KtInvocationContext) : + JavaInvocationContext(ktInvocationContextJavaBuilder(ic)) { + + override fun invocationId(): String = ic.invocationId + + override fun branch(): Optional = Optional.ofNullable(ic.branch) + + // Throws rather than silently doing nothing: the Kotlin branch is immutable, and only the engine + // deepens it when entering a child agent. Python ADK likewise keeps it read-only on the + // user-facing context, and ADK Java's setter has no callers. + override fun branch(branch: String?) = + throw UnsupportedOperationException( + "A Kotlin invocation's branch is immutable, so a bridged Java component cannot re-scope it" + ) + + override fun endInvocation(): Boolean = ic.isEndOfInvocation + + override fun setEndInvocation(endInvocation: Boolean) { + ic.isEndOfInvocation = endInvocation + } + + @OptIn(FrameworkInternalApi::class) + override fun callbackContextData(): MutableMap = ic.frameworkData.callbackContextData +} + +/** + * Converts a Kotlin [KtToolContext] to a Java [JavaToolContext]. The tool's side effects stay live + * because the invocation context and actions delegate to the Kotlin context by reference. + */ +internal fun ktToolContextToJava(context: KtToolContext): JavaToolContext { + val builder = + JavaToolContext.builder(KtInvocationContextToJavaView(context.invocationContext)) + .actions(KtEventActionsToJavaView(context.actions)) + .functionCallId(context.functionCallId) + .eventId(context.eventId) + // On resume, the Kotlin re-runs the tool with the user's confirmation set; carry it so a Java + // tool (e.g. a requireConfirmation FunctionTool) sees it and proceeds instead of re-requesting. + context.toolConfirmation?.let { builder.toolConfirmation(ToolConfirmationCodec.toJava(it)) } + return builder.build() +} diff --git a/tokt/src/main/java/com/google/adk/tokt/services/JavaArtifactServiceToKt.kt b/tokt/src/main/java/com/google/adk/tokt/services/JavaArtifactServiceToKt.kt new file mode 100644 index 000000000..988f3b053 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/services/JavaArtifactServiceToKt.kt @@ -0,0 +1,117 @@ +/* + * 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.services + +import com.google.adk.artifacts.BaseArtifactService as JavaBaseArtifactService +import com.google.adk.kt.artifacts.ArtifactService as KtArtifactService +import com.google.adk.kt.sessions.SessionKey +import com.google.adk.kt.types.Part as KtPart +import com.google.adk.tokt.InteropDispatcher +import com.google.adk.tokt.codecs.PartCodec +import com.google.adk.tokt.codecs.sessionId +import kotlinx.coroutines.rx3.await +import kotlinx.coroutines.rx3.awaitSingleOrNull +import kotlinx.coroutines.withContext + +/** + * A Kotlin [KtArtifactService] backed by an ADK Java [JavaBaseArtifactService] - the reverse of + * [KtArtifactServiceToJava] - so the Kotlin runner can drive a Java app's own artifact service when + * a Java `Runner` runs on the Kotlin engine. Parts are converted with [PartCodec]. All Java-service + * calls run on InteropDispatcher since a user's artifact service may block on subscribe. + */ +internal class JavaArtifactServiceToKt(internal val service: JavaBaseArtifactService) : + KtArtifactService { + + override suspend fun saveArtifact( + sessionKey: SessionKey, + filename: String, + artifact: KtPart, + ): Int = + withContext(InteropDispatcher) { + service + .saveArtifact( + sessionKey.appName, + sessionKey.userId, + sessionId(sessionKey), + filename, + PartCodec.toJavaOrThrow(artifact), + ) + .await() + } + + override suspend fun saveAndReloadArtifact( + sessionKey: SessionKey, + filename: String, + artifact: KtPart, + ): KtPart = + withContext(InteropDispatcher) { + PartCodec.fromJavaOrThrow( + service + .saveAndReloadArtifact( + sessionKey.appName, + sessionKey.userId, + sessionId(sessionKey), + filename, + PartCodec.toJavaOrThrow(artifact), + ) + .await() + ) + } + + override suspend fun loadArtifact( + sessionKey: SessionKey, + filename: String, + version: Int?, + ): KtPart? = + withContext(InteropDispatcher) { + service + .loadArtifact( + sessionKey.appName, + sessionKey.userId, + sessionId(sessionKey), + filename, + version, + ) + .awaitSingleOrNull() + // fromJavaOrThrow, not fromJava: an artifact that exists but cannot be converted must be + // rejected, not reported as absent. + ?.let { PartCodec.fromJavaOrThrow(it) } + } + + override suspend fun listArtifactKeys(sessionKey: SessionKey): List = + withContext(InteropDispatcher) { + service + .listArtifactKeys(sessionKey.appName, sessionKey.userId, sessionId(sessionKey)) + .await() + .filenames() + } + + override suspend fun deleteArtifact(sessionKey: SessionKey, filename: String) { + withContext(InteropDispatcher) { + service + .deleteArtifact(sessionKey.appName, sessionKey.userId, sessionId(sessionKey), filename) + .await() + } + } + + override suspend fun listVersions(sessionKey: SessionKey, filename: String): List = + withContext(InteropDispatcher) { + service + .listVersions(sessionKey.appName, sessionKey.userId, sessionId(sessionKey), filename) + .await() + } +} diff --git a/tokt/src/main/java/com/google/adk/tokt/services/JavaMemoryServiceToKt.kt b/tokt/src/main/java/com/google/adk/tokt/services/JavaMemoryServiceToKt.kt new file mode 100644 index 000000000..1fabe218e --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/services/JavaMemoryServiceToKt.kt @@ -0,0 +1,50 @@ +/* + * 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.services + +import com.google.adk.kt.memory.MemoryService as KtMemoryService +import com.google.adk.kt.memory.SearchMemoryResponse as KtSearchMemoryResponse +import com.google.adk.kt.sessions.Session as KtSession +import com.google.adk.memory.BaseMemoryService as JavaBaseMemoryService +import com.google.adk.tokt.InteropDispatcher +import com.google.adk.tokt.codecs.MemoryEntryCodec +import com.google.adk.tokt.codecs.ktSessionToJava +import kotlinx.coroutines.rx3.await +import kotlinx.coroutines.withContext + +/** + * A Kotlin [KtMemoryService] backed by an ADK Java [JavaBaseMemoryService] - the reverse of + * [KtMemoryServiceToJava] - so the Kotlin runner can drive a Java app's own memory service when a + * Java `Runner` runs on the Kotlin engine. + */ +internal class JavaMemoryServiceToKt(internal val service: JavaBaseMemoryService) : + KtMemoryService { + + override suspend fun addSessionToMemory(session: KtSession) { + // On InteropDispatcher: a user's Java memory service may block on subscribe. + withContext(InteropDispatcher) { service.addSessionToMemory(ktSessionToJava(session)).await() } + } + + override suspend fun searchMemory( + appName: String, + userId: String, + query: String, + ): KtSearchMemoryResponse = + withContext(InteropDispatcher) { + MemoryEntryCodec.fromJava(service.searchMemory(appName, userId, query).await()) + } +} diff --git a/tokt/src/main/java/com/google/adk/tokt/services/JavaSessionServiceToKt.kt b/tokt/src/main/java/com/google/adk/tokt/services/JavaSessionServiceToKt.kt new file mode 100644 index 000000000..6875d8442 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/services/JavaSessionServiceToKt.kt @@ -0,0 +1,127 @@ +/* + * 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.services + +import com.google.adk.kt.events.Event as KtEvent +import com.google.adk.kt.sessions.GetSessionConfig as KtGetSessionConfig +import com.google.adk.kt.sessions.ListEventsResponse as KtListEventsResponse +import com.google.adk.kt.sessions.ListSessionsResponse as KtListSessionsResponse +import com.google.adk.kt.sessions.Session as KtSession +import com.google.adk.kt.sessions.SessionKey +import com.google.adk.kt.sessions.SessionService as KtSessionService +import com.google.adk.sessions.BaseSessionService as JavaBaseSessionService +import com.google.adk.sessions.GetSessionConfig as JavaGetSessionConfig +import com.google.adk.tokt.InteropDispatcher +import com.google.adk.tokt.codecs.EventCodec +import com.google.adk.tokt.codecs.SessionCodec +import com.google.adk.tokt.codecs.ktSessionToJava +import com.google.adk.tokt.codecs.sessionId +import java.util.Optional +import kotlin.time.toJavaInstant +import kotlin.time.toKotlinInstant +import kotlinx.coroutines.rx3.await +import kotlinx.coroutines.rx3.awaitSingleOrNull +import kotlinx.coroutines.withContext + +/** + * A Kotlin [KtSessionService] backed by an ADK Java [JavaBaseSessionService] - the reverse of + * [KtSessionServiceToJava]. It lets the Kotlin runner drive a Java app's own session service + * (in-memory, Vertex AI, custom, ...) when a Java `Runner` runs on the Kotlin engine. Each call + * converts arguments, suspends on the Java service's RxJava result, and converts back. + * + * `appendEvent` persists through the Java service (keyed by appName/userId/id) and then keeps the + * in-memory Kotlin [session] the runner holds in sync via the default implementation. + */ +internal class JavaSessionServiceToKt(internal val service: JavaBaseSessionService) : + KtSessionService { + + // All Java-service calls are awaited on InteropDispatcher: a user's Java service (in-memory, + // Vertex, + // custom) may be blocking-on-subscribe, so it must not run on the coroutine driving the agent + // loop. + + override suspend fun createSession(key: SessionKey, state: Map?): KtSession = + withContext(InteropDispatcher) { + SessionCodec.fromJava(service.createSession(key.appName, key.userId, state, key.id).await()) + } + + override suspend fun getSession(key: SessionKey, config: KtGetSessionConfig?): KtSession? = + withContext(InteropDispatcher) { + service + .getSession(key.appName, key.userId, sessionId(key), Optional.ofNullable(config?.toJava())) + .awaitSingleOrNull() + ?.let { SessionCodec.fromJava(it) } + } + + override suspend fun listSessions(appName: String, userId: String): KtListSessionsResponse = + withContext(InteropDispatcher) { + KtListSessionsResponse( + sessions = + service.listSessions(appName, userId).await().sessions().map { SessionCodec.fromJava(it) } + ) + } + + override suspend fun closeSession(session: KtSession) { + withContext(InteropDispatcher) { service.closeSession(ktSessionToJava(session)).await() } + } + + override suspend fun deleteSession(key: SessionKey) { + withContext(InteropDispatcher) { + service.deleteSession(key.appName, key.userId, sessionId(key)).await() + } + } + + override suspend fun listEvents(key: SessionKey): KtListEventsResponse { + // A well-behaved Java service always returns a response; tolerate a null Single/response (e.g. + // an unstubbed test double) as "no events" rather than crashing the Kotlin run. + val response = + withContext(InteropDispatcher) { + service.listEvents(key.appName, key.userId, sessionId(key))?.await() + } + return KtListEventsResponse( + events = response?.events().orEmpty().map { EventCodec.fromJava(it) } + ) + } + + override suspend fun appendEvent(session: KtSession, event: KtEvent): KtEvent { + // Persist through the Java service first: the converted Java session carries the prior events, + // so the service appends and persists this event (keyed by appName/userId/id). + val javaSession = ktSessionToJava(session) + withContext(InteropDispatcher) { + service.appendEvent(javaSession, EventCodec.toJava(event)).await() + } + // Keep the in-memory Kotlin session the runner holds in sync (state delta + event list); this + // also advances lastUpdateTime to the event timestamp. + val appended = super.appendEvent(session, event) + // Mirror the Java service's lastUpdateTime only when it moved further forward: `javaSession` is + // the pre-append snapshot, and BaseSessionService.appendEvent does not touch the field, so + // assigning it unconditionally would rewind the session for every service that leaves it alone. + javaSession + .lastUpdateTime() + .toKotlinInstant() + .takeIf { it > session.lastUpdateTime } + ?.let { session.lastUpdateTime = it } + return appended + } + + private fun KtGetSessionConfig.toJava(): JavaGetSessionConfig { + val builder = JavaGetSessionConfig.builder() + numRecentEvents?.let { builder.numRecentEvents(it) } + afterTimestamp?.let { builder.afterTimestamp(it.toJavaInstant()) } + return builder.build() + } +} diff --git a/tokt/src/main/java/com/google/adk/tokt/services/KtArtifactServiceToJava.kt b/tokt/src/main/java/com/google/adk/tokt/services/KtArtifactServiceToJava.kt new file mode 100644 index 000000000..99ba337af --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/services/KtArtifactServiceToJava.kt @@ -0,0 +1,127 @@ +/* + * 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.services + +import com.google.adk.artifacts.BaseArtifactService as JavaBaseArtifactService +import com.google.adk.artifacts.ListArtifactsResponse as JavaListArtifactsResponse +import com.google.adk.kt.artifacts.ArtifactService as KtArtifactService +import com.google.adk.kt.sessions.SessionKey +import com.google.adk.tokt.InteropDispatcher +import com.google.adk.tokt.codecs.PartCodec +import com.google.common.collect.ImmutableList +import com.google.genai.types.Part as GenaiPart +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Maybe +import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.rx3.rxCompletable +import kotlinx.coroutines.rx3.rxMaybe +import kotlinx.coroutines.rx3.rxSingle + +/** + * A Java [JavaBaseArtifactService] backed by a Kotlin [KtArtifactService] - the reverse of the Java + * service wrappers - so a Java agent running under the Kotlin runner sees a Java artifact service + * backed by the Kotlin service. Artifacts are converted with [PartCodec]. + */ +internal class KtArtifactServiceToJava(internal val service: KtArtifactService) : + JavaBaseArtifactService { + + override fun saveArtifact( + appName: String, + userId: String, + sessionId: String, + filename: String, + artifact: GenaiPart, + ): Single = + rxSingle(InteropDispatcher) { + service.saveArtifact( + SessionKey(appName, userId, sessionId), + filename, + PartCodec.fromJavaOrThrow(artifact), + ) + } + + // Override so a Java caller gets the Kotlin service's single-shot save-and-reload rather than the + // Java default (a separate saveArtifact + loadArtifact round-trip). + override fun saveAndReloadArtifact( + appName: String, + userId: String, + sessionId: String, + filename: String, + artifact: GenaiPart, + ): Single = + rxSingle(InteropDispatcher) { + PartCodec.toJavaOrThrow( + service.saveAndReloadArtifact( + SessionKey(appName, userId, sessionId), + filename, + PartCodec.fromJavaOrThrow(artifact), + ) + ) + } + + override fun loadArtifact( + appName: String, + userId: String, + sessionId: String, + filename: String, + version: Int?, + ): Maybe = + rxMaybe(InteropDispatcher) { + // toJavaOrThrow, not toJava: an artifact that exists but cannot be converted must be + // rejected, not reported as absent. + service.loadArtifact(SessionKey(appName, userId, sessionId), filename, version)?.let { + PartCodec.toJavaOrThrow(it) + } + } + + // Suppressed: the Java service's return type is a Guava ImmutableList, so building one + // directly avoids a copy the Kotlin API would force. + @Suppress("PreferKotlinApi") + override fun listArtifactKeys( + appName: String, + userId: String, + sessionId: String, + ): Single = + rxSingle(InteropDispatcher) { + JavaListArtifactsResponse.builder() + .filenames( + ImmutableList.copyOf(service.listArtifactKeys(SessionKey(appName, userId, sessionId))) + ) + .build() + } + + override fun deleteArtifact( + appName: String, + userId: String, + sessionId: String, + filename: String, + ): Completable = + rxCompletable(InteropDispatcher) { + service.deleteArtifact(SessionKey(appName, userId, sessionId), filename) + } + + @Suppress("PreferKotlinApi") + override fun listVersions( + appName: String, + userId: String, + sessionId: String, + filename: String, + ): Single> = + rxSingle(InteropDispatcher) { + ImmutableList.copyOf(service.listVersions(SessionKey(appName, userId, sessionId), filename)) + } +} diff --git a/tokt/src/main/java/com/google/adk/tokt/services/KtMemoryServiceToJava.kt b/tokt/src/main/java/com/google/adk/tokt/services/KtMemoryServiceToJava.kt new file mode 100644 index 000000000..7a0c139bc --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/services/KtMemoryServiceToJava.kt @@ -0,0 +1,50 @@ +/* + * 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.services + +import com.google.adk.kt.memory.MemoryService as KtMemoryService +import com.google.adk.memory.BaseMemoryService as JavaBaseMemoryService +import com.google.adk.memory.SearchMemoryResponse as JavaSearchMemoryResponse +import com.google.adk.sessions.Session as JavaSession +import com.google.adk.tokt.InteropDispatcher +import com.google.adk.tokt.codecs.MemoryEntryCodec +import com.google.adk.tokt.codecs.SessionCodec +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.rx3.rxCompletable +import kotlinx.coroutines.rx3.rxSingle + +/** + * A Java [JavaBaseMemoryService] backed by a Kotlin [KtMemoryService] - the reverse of the Java + * service wrappers - so a Java agent running under the Kotlin runner sees a Java memory service + * backed by the Kotlin service. + */ +internal class KtMemoryServiceToJava(internal val service: KtMemoryService) : + JavaBaseMemoryService { + + override fun addSessionToMemory(session: JavaSession): Completable = + rxCompletable(InteropDispatcher) { service.addSessionToMemory(SessionCodec.fromJava(session)) } + + override fun searchMemory( + appName: String, + userId: String, + query: String, + ): Single = + rxSingle(InteropDispatcher) { + MemoryEntryCodec.toJava(service.searchMemory(appName, userId, query)) + } +} diff --git a/tokt/src/main/java/com/google/adk/tokt/services/KtSessionServiceToJava.kt b/tokt/src/main/java/com/google/adk/tokt/services/KtSessionServiceToJava.kt new file mode 100644 index 000000000..286df1155 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/services/KtSessionServiceToJava.kt @@ -0,0 +1,141 @@ +/* + * 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.services + +import com.google.adk.events.Event as JavaEvent +import com.google.adk.kt.sessions.GetSessionConfig as KtGetSessionConfig +import com.google.adk.kt.sessions.SessionKey +import com.google.adk.kt.sessions.SessionService as KtSessionService +import com.google.adk.sessions.BaseSessionService as JavaBaseSessionService +import com.google.adk.sessions.GetSessionConfig as JavaGetSessionConfig +import com.google.adk.sessions.ListEventsResponse as JavaListEventsResponse +import com.google.adk.sessions.ListSessionsResponse as JavaListSessionsResponse +import com.google.adk.sessions.Session as JavaSession +import com.google.adk.tokt.InteropDispatcher +import com.google.adk.tokt.codecs.EventCodec +import com.google.adk.tokt.codecs.KtBackedEventsView +import com.google.adk.tokt.codecs.SessionCodec +import com.google.adk.tokt.codecs.ktSessionToJava +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Maybe +import io.reactivex.rxjava3.core.Single +import java.util.Optional +import java.util.concurrent.ConcurrentMap +import kotlin.jvm.optionals.getOrNull +import kotlin.time.toJavaInstant +import kotlin.time.toKotlinInstant +import kotlinx.coroutines.rx3.rxCompletable +import kotlinx.coroutines.rx3.rxMaybe +import kotlinx.coroutines.rx3.rxSingle + +/** + * A Java [JavaBaseSessionService] backed by a Kotlin [KtSessionService] (reverse of the Java + * service wrappers): a Java agent running on the Kotlin runner sees a Java session service whose + * operations run on the Kotlin service. + */ +internal class KtSessionServiceToJava(internal val service: KtSessionService) : + JavaBaseSessionService { + + @Deprecated("Deprecated in BaseSessionService") + override fun createSession( + appName: String, + userId: String, + state: ConcurrentMap?, + sessionId: String?, + ): Single = + rxSingle(InteropDispatcher) { + ktSessionToJava(service.createSession(SessionKey(appName, userId, sessionId), state)) + } + + override fun getSession( + appName: String, + userId: String, + sessionId: String, + config: Optional, + ): Maybe = + rxMaybe(InteropDispatcher) { + service + .getSession(SessionKey(appName, userId, sessionId), config.getOrNull()?.toKotlin()) + ?.let { ktSessionToJava(it) } + } + + override fun listSessions(appName: String, userId: String): Single = + rxSingle(InteropDispatcher) { + val response = service.listSessions(appName, userId) + JavaListSessionsResponse.builder() + .sessions(response.sessions.map { ktSessionToJava(it) }) + .build() + } + + override fun closeSession(session: JavaSession): Completable = + rxCompletable(InteropDispatcher) { service.closeSession(SessionCodec.fromJava(session)) } + + override fun deleteSession(appName: String, userId: String, sessionId: String): Completable = + rxCompletable(InteropDispatcher) { + service.deleteSession(SessionKey(appName, userId, sessionId)) + } + + override fun listEvents( + appName: String, + userId: String, + sessionId: String, + ): Single = + rxSingle(InteropDispatcher) { + val response = service.listEvents(SessionKey(appName, userId, sessionId)) + JavaListEventsResponse.builder().events(response.events.map { EventCodec.toJava(it) }).build() + } + + /** + * Appends [event] to [session] on the Kotlin service, then mirrors its stored session (merged + * state, events, and last-update time) back into the caller's Java [session] so ADK Java's + * `Runner` keeps observing the appended state in place. + */ + override fun appendEvent(session: JavaSession, event: JavaEvent): Single = + rxSingle(InteropDispatcher) { + val key = SessionKey(session.appName(), session.userId(), session.id()) + service.appendEvent(SessionCodec.fromJava(session), EventCodec.fromJava(event)) + service.getSession(key)?.let { stored -> + // Convert before touching the caller's session, then refill under the list's monitor so a + // concurrent reader never observes a transiently-empty event list. Session.events() is a + // Collections.synchronizedList, so its own monitor is the correct lock to hold here. + // Mirror only into a session we can actually write. The live view this module hands out + // (ktSessionToJavaLive, e.g. invocationContext.session()) is already backed by the Kotlin + // session: its events() is a read-only converting view that throws on clear/addAll, and + // its state() writes straight through, so it needs no mirroring and must not be mutated + // here. A plain snapshot session (ktSessionToJava) does. + if (session.events() is MutableList<*> && session.events() !is KtBackedEventsView) { + val storedEvents = stored.events.map { EventCodec.toJava(it) } + synchronized(session.events()) { + session.events().clear() + session.events().addAll(storedEvents) + } + // No lock: State is a ConcurrentMap, so readers never take this monitor. Drop stale keys + // and overwrite in place, leaving no transiently-empty window. + session.state().keys.retainAll(stored.state.keys) + session.state().putAll(stored.state) + } + session.lastUpdateTime(stored.lastUpdateTime.toJavaInstant()) + } + event + } + + private fun JavaGetSessionConfig.toKotlin(): KtGetSessionConfig = + KtGetSessionConfig( + numRecentEvents = numRecentEvents().getOrNull(), + afterTimestamp = afterTimestamp().getOrNull()?.toKotlinInstant(), + ) +} diff --git a/tokt/src/main/java/com/google/adk/tokt/services/ServiceAdapters.kt b/tokt/src/main/java/com/google/adk/tokt/services/ServiceAdapters.kt new file mode 100644 index 000000000..384ba6d90 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/services/ServiceAdapters.kt @@ -0,0 +1,48 @@ +/* + * 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.services + +import com.google.adk.artifacts.BaseArtifactService as JavaBaseArtifactService +import com.google.adk.kt.artifacts.ArtifactService as KtArtifactService +import com.google.adk.kt.memory.MemoryService as KtMemoryService +import com.google.adk.kt.sessions.SessionService as KtSessionService +import com.google.adk.memory.BaseMemoryService as JavaBaseMemoryService +import com.google.adk.sessions.BaseSessionService as JavaBaseSessionService + +/** + * Service adapter factories that unwrap a round-tripped service rather than stacking adapters: + * exposing a Kotlin service that is itself a wrapped Java service returns the original Java service + * (and vice versa), so a Java -> Kt -> Java round-trip collapses to one reference with no extra + * hop. + */ +internal fun ktSessionServiceAsJava(service: KtSessionService): JavaBaseSessionService = + (service as? JavaSessionServiceToKt)?.service ?: KtSessionServiceToJava(service) + +internal fun javaSessionServiceAsKt(service: JavaBaseSessionService): KtSessionService = + (service as? KtSessionServiceToJava)?.service ?: JavaSessionServiceToKt(service) + +internal fun ktArtifactServiceAsJava(service: KtArtifactService): JavaBaseArtifactService = + (service as? JavaArtifactServiceToKt)?.service ?: KtArtifactServiceToJava(service) + +internal fun javaArtifactServiceAsKt(service: JavaBaseArtifactService): KtArtifactService = + (service as? KtArtifactServiceToJava)?.service ?: JavaArtifactServiceToKt(service) + +internal fun ktMemoryServiceAsJava(service: KtMemoryService): JavaBaseMemoryService = + (service as? JavaMemoryServiceToKt)?.service ?: KtMemoryServiceToJava(service) + +internal fun javaMemoryServiceAsKt(service: JavaBaseMemoryService): KtMemoryService = + (service as? KtMemoryServiceToJava)?.service ?: JavaMemoryServiceToKt(service) diff --git a/tokt/src/test/java/com/google/adk/tokt/KtRunnerInteropLiveTest.kt b/tokt/src/test/java/com/google/adk/tokt/KtRunnerInteropLiveTest.kt new file mode 100644 index 000000000..f3571ce8d --- /dev/null +++ b/tokt/src/test/java/com/google/adk/tokt/KtRunnerInteropLiveTest.kt @@ -0,0 +1,547 @@ +/* + * 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.artifacts.InMemoryArtifactService as JavaInMemoryArtifactService +import com.google.adk.kt.agents.BaseAgent +import com.google.adk.kt.agents.Instruction +import com.google.adk.kt.agents.LlmAgent +import com.google.adk.kt.agents.ResumabilityConfig +import com.google.adk.kt.agents.RunConfig +import com.google.adk.kt.apps.App +import com.google.adk.kt.events.Event +import com.google.adk.kt.runners.InMemoryRunner +import com.google.adk.kt.sessions.SessionKey +import com.google.adk.kt.types.Content +import com.google.adk.kt.types.FunctionCall +import com.google.adk.kt.types.FunctionResponse +import com.google.adk.kt.types.GenerateContentConfig +import com.google.adk.kt.types.HarmBlockThreshold +import com.google.adk.kt.types.HarmCategory +import com.google.adk.kt.types.Part +import com.google.adk.kt.types.SafetySetting +import com.google.adk.kt.types.Schema +import com.google.adk.kt.types.ThinkingConfig +import com.google.adk.kt.types.Type +import com.google.adk.memory.InMemoryMemoryService as JavaInMemoryMemoryService +import com.google.adk.models.BaseLlm as JavaBaseLlm +import com.google.adk.models.Gemini as JavaGemini +import com.google.adk.plugins.GlobalInstructionPlugin +import com.google.adk.plugins.LoggingPlugin +import com.google.adk.sessions.InMemorySessionService as JavaInMemorySessionService +import com.google.adk.tools.FunctionTool +import com.google.adk.tools.LongRunningFunctionTool +import com.google.genai.Client +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.rules.Timeout + +/** + * Live integration tests: real ADK **Java** tools, toolsets, plugins, models and services running + * on the ADK **Kotlin** engine through the `tokt` forward interop, against the real Gemini API. + * + * Like ADK Java's other API-key-gated integration tests, each test is **skipped** (JUnit + * assumption) unless `GOOGLE_API_KEY` is set, so hermetic runs pass without it. Uses the Gemini + * Developer API (AI Studio), not Vertex. Assertions are deliberately **structural** (which calls / + * responses / authors / state appeared), never on model prose, so wording cannot make them flaky. + * + * Run: `GOOGLE_API_KEY=... ./mvnw -pl tokt test -Dtest=KtRunnerInteropLiveTest`. + */ +class KtRunnerInteropLiveTest { + + /** Bounds a hung API call, which would otherwise stall the whole run. */ + @get:Rule val timeout: Timeout = Timeout.seconds(180) + + private fun apiKey(): String { + val key = System.getenv("GOOGLE_API_KEY") + assumeTrue("GOOGLE_API_KEY not set; skipping live interop test.", !key.isNullOrBlank()) + return key!! + } + + /** A real ADK Java Gemini model over the Gemini Developer API (AI Studio), by API key. */ + private fun javaModel(key: String): JavaBaseLlm = + JavaGemini(MODEL, Client.builder().apiKey(key).vertexAI(false).build()) + + /** A modest output cap so a chatty live turn does not run to MAX_TOKENS. */ + private fun cfg(maxOutputTokens: Int = 512, thinking: Boolean = false) = + GenerateContentConfig( + maxOutputTokens = maxOutputTokens, + thinkingConfig = if (thinking) ThinkingConfig(includeThoughts = true) else null, + ) + + /** + * The other live tests send a near-empty generation config, so the request the codec builds is + * barely exercised against a real endpoint. A hermetic test can only prove a field reached the + * Java object; only a real call proves the API accepts the request built from it - which is how a + * wrongly-cased enum reached review once already. + */ + @Test + fun richGenerateContentConfig_isAcceptedByTheRealApi() = runBlocking { + val key = apiKey() + val agent = + LlmAgent( + name = "assistant", + model = JavaAdkToKt.asKtModel(javaModel(key)), + instruction = Instruction("Answer using the requested JSON shape."), + generateContentConfig = + GenerateContentConfig( + temperature = 0.2f, + topP = 0.9f, + topK = 40, + maxOutputTokens = 512, + stopSequences = listOf("NEVER_EMITTED"), + systemInstruction = Content.fromText("system", "You are terse."), + responseMimeType = "application/json", + responseSchema = + Schema( + type = Type.OBJECT, + properties = + mapOf( + "city" to Schema(type = Type.STRING, description = "The city named."), + "population" to Schema(type = Type.INTEGER), + ), + required = listOf("city", "population"), + ), + safetySettings = + listOf( + SafetySetting( + category = HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold = HarmBlockThreshold.BLOCK_ONLY_HIGH, + ) + ), + thinkingConfig = ThinkingConfig(includeThoughts = true), + ), + ) + val runner = InMemoryRunner(app = App(appName = "live", rootAgent = agent)) + + val events = + runner + .runAsync( + userId = "u", + sessionId = "s", + newMessage = Content.fromText("user", "Give me the population of Warsaw."), + runConfig = RunConfig(maxLlmCalls = 4), + ) + .toList() + + // The API accepting the request is most of the point, so surface its complaint rather than a + // bare "no text" if the converted config was malformed. + val error = events.firstNotNullOfOrNull { it.errorMessage } + assertTrue(error == null, "the converted rich config should be accepted, got: $error") + + // responseSchema and responseMimeType really took effect: the reply is a JSON object with the + // required keys, not prose. + val text = + assertNotNull( + events.firstNotNullOfOrNull { e -> + e.content?.parts?.firstNotNullOfOrNull { it.text?.takeIf { t -> t.isNotBlank() } } + }, + "expected a model reply", + ) + val json = text.trim() + assertTrue(json.startsWith("{"), "responseMimeType=application/json should yield JSON: $json") + assertTrue(json.contains("\"city\""), "responseSchema should force a 'city' key: $json") + assertTrue(json.contains("\"population\""), "responseSchema should force 'population': $json") + } + + @Test + fun javaToolsToolsetAndServices_onKotlinAgent() = runBlocking { + val key = apiKey() + val toolset = LiveInteropTools.MathToolset() + val agent = + LlmAgent( + name = "assistant", + model = JavaAdkToKt.asKtModel(javaModel(key)), + instruction = Instruction("Use the provided tools when relevant, then answer briefly."), + tools = JavaAdkToKt.asKtTools(listOf(tool("getWeather"), tool("rememberColor"))), + toolsets = listOf(JavaAdkToKt.asKtToolset(toolset)), + generateContentConfig = cfg(), + ) + val runner = + InMemoryRunner( + app = App(appName = "live", rootAgent = agent), + sessionService = JavaAdkToKt.asKtSessionService(JavaInMemorySessionService()), + artifactService = JavaAdkToKt.asKtArtifactService(JavaInMemoryArtifactService()), + memoryService = JavaAdkToKt.asKtMemoryService(JavaInMemoryMemoryService()), + ) + + val events = + runner + .runAsync( + userId = "u", + sessionId = "s", + newMessage = + Content.fromText("user", "What's the weather in Warsaw, and what is 21 + 21?"), + runConfig = RunConfig(maxLlmCalls = 8), + ) + .toList() + + assertTrue( + responseNames(events).contains("getWeather"), + "the Java FunctionTool should have run; saw ${responseNames(events)}", + ) + assertTrue( + toolset.provisionedForAgents().isNotEmpty(), + "the Java toolset should have been provisioned with a non-null ReadonlyContext", + ) + assertEquals( + "assistant", + toolset.provisionedForAgents().first(), + "the toolset's ReadonlyContext should report the running agent", + ) + } + + @Test + fun transferWithJavaPlugins_keepsTransferToAgentTool() = runBlocking { + // A bridged beforeModel plugin mutates the request; it must NOT drop the engine's + // transfer_to_agent tool, or multi-agent routing silently stops working. + val key = apiKey() + val weather = + LlmAgent( + name = "weather_agent", + description = "Answers questions about the weather in a city.", + model = JavaAdkToKt.asKtModel(javaModel(key)), + instruction = Instruction("Use the getWeather tool, then answer."), + tools = JavaAdkToKt.asKtTools(listOf(tool("getWeather"))), + generateContentConfig = cfg(), + ) + val math = + LlmAgent( + name = "math_agent", + description = "Answers arithmetic questions.", + model = JavaAdkToKt.asKtModel(javaModel(key)), + instruction = Instruction("Use the add tool, then answer."), + tools = JavaAdkToKt.asKtTools(listOf(tool("add"))), + generateContentConfig = cfg(), + ) + val router = + LlmAgent( + name = "router", + description = "Routes the user request to the best sub-agent.", + model = JavaAdkToKt.asKtModel(javaModel(key)), + instruction = Instruction("Transfer the user's request to the most suitable sub-agent."), + subAgents = listOf(weather, math), + generateContentConfig = cfg(), + ) + val runner = + InMemoryRunner( + App( + appName = "live", + rootAgent = router, + plugins = + JavaAdkToKt.asKtPlugins( + listOf(LoggingPlugin(), GlobalInstructionPlugin("Be concise and name the tool used.")) + ), + ) + ) + + val events = + runner + .runAsync( + userId = "u", + sessionId = "s", + newMessage = Content.fromText("user", "What's the weather in Tokyo?"), + runConfig = RunConfig(maxLlmCalls = 10), + ) + .toList() + + assertTrue( + events.any { it.author == "weather_agent" }, + "transfer must survive the bridged beforeModel plugin (weather_agent never ran)", + ) + } + + @Test + fun javaPluginAfterTool_writesAndRemovesState() = runBlocking { + val key = apiKey() + val agent = + LlmAgent( + name = "assistant", + model = JavaAdkToKt.asKtModel(javaModel(key)), + instruction = + Instruction("When the user shares a favorite color, call rememberColor to store it."), + tools = JavaAdkToKt.asKtTools(listOf(tool("rememberColor"))), + generateContentConfig = cfg(), + ) + val runner = + InMemoryRunner( + App( + appName = "live", + rootAgent = agent, + plugins = JavaAdkToKt.asKtPlugins(listOf(LiveInteropTools.StateProbePlugin())), + ) + ) + + runner + .runAsync( + userId = "u", + sessionId = "s", + newMessage = Content.fromText("user", "My favorite color is teal - please remember it."), + runConfig = RunConfig(maxLlmCalls = 6), + ) + .toList() + + val state = runner.sessionService.getSession(SessionKey("live", "u", "s"))!!.state + assertEquals("teal", state["favorite_color"], "the Java tool's state write should persist") + assertEquals( + "rememberColor", + state["last_tool"], + "the Java plugin's afterTool state write should persist", + ) + assertTrue( + !state.containsKey("probe_temp"), + "a key removed in the Java plugin's afterTool callback must not persist", + ) + assertEquals( + "temp", + state["probe_removed_value"], + "removing a key must return the value it held, through the bridged state map", + ) + } + + @Test + fun thinkingModel_roundTripsThoughtSignatures() = runBlocking { + val key = apiKey() + val agent = + LlmAgent( + name = "thinker", + model = JavaAdkToKt.asKtModel(javaModel(key)), + instruction = + Instruction("Think it through, use getWeather if useful, then answer briefly."), + tools = JavaAdkToKt.asKtTools(listOf(tool("getWeather"))), + generateContentConfig = cfg(maxOutputTokens = 2048, thinking = true), + ) + val runner = InMemoryRunner(agent, appName = "live") + + val events = + runner + .runAsync( + userId = "u", + sessionId = "s", + newMessage = Content.fromText("user", "Should I bring an umbrella in Seattle today?"), + runConfig = RunConfig(maxLlmCalls = 8), + ) + .toList() + + // Reaching a tool proves the thought signature survived the round-trip: the follow-up request + // replays the prior turn's parts, and a dropped or corrupted signature makes the API reject it. + assertTrue( + responseNames(events).contains("getWeather"), + "the thinking model should have completed a tool round-trip; saw ${responseNames(events)}", + ) + } + + @Test + fun javaLongRunningTool_surfacesCallThenResumes() = runBlocking { + val agent = + LlmAgent( + name = "approver", + model = JavaAdkToKt.asKtModel(javaModel(apiKey())), + instruction = + Instruction( + "To approve spending you MUST call requestApproval. Once the decision arrives, tell" + + " the user the outcome in one sentence." + ), + tools = + JavaAdkToKt.asKtTools( + listOf(LongRunningFunctionTool.create(LiveInteropTools::class.java, "requestApproval")) + ), + generateContentConfig = cfg(), + ) + assertApprovalPausesThenResumes(agent) + } + + @Test + fun javaRequireConfirmationTool_gatesThenRuns() = runBlocking { + val agent = + LlmAgent( + name = "fileops", + model = JavaAdkToKt.asKtModel(javaModel(apiKey())), + instruction = Instruction("To delete a file, call deleteFile with its path."), + tools = + JavaAdkToKt.asKtTools( + listOf( + FunctionTool.create( + LiveInteropTools::class.java, + "deleteFile", + /* requireConfirmation= */ true, + ) + ) + ), + generateContentConfig = cfg(), + ) + assertConfirmationGatesThenRuns(agent) + } + + /** + * Drives the human-in-the-loop shape against the live model: turn 1 must surface the pending + * long-running call rather than a decision, and a turn 2 carrying the out-of-band answer must + * produce a final reply. + */ + private suspend fun assertApprovalPausesThenResumes(rootAgent: BaseAgent) { + val runner = InMemoryRunner(resumableApp(rootAgent)) + val turn1 = + runner + .runAsync( + userId = "u", + sessionId = "s", + newMessage = + Content.fromText("user", "Please request approval to spend \$500 on laptops."), + runConfig = RunConfig(maxLlmCalls = 6), + ) + .toList() + + val call = + assertNotNull( + pendingLongRunningCall(turn1, "requestApproval"), + "the long-running call should be surfaced with its id in longRunningToolIds", + ) + // A long-running tool still returns immediately; what makes it long-running is that the answer + // is a placeholder and the real decision arrives out of band on a later turn. + assertEquals( + "PENDING", + responseFor(turn1, "requestApproval")?.get("status"), + "turn 1 should carry the tool's pending placeholder, not a decision", + ) + + val turn2 = + runner + .runAsync( + userId = "u", + sessionId = "s", + newMessage = + functionResponseTurn("requestApproval", call.id, mapOf("status" to "APPROVED")), + runConfig = RunConfig(maxLlmCalls = 6), + ) + .toList() + + assertTrue( + turn2.flatMap { it.content?.parts.orEmpty() }.any { !it.text.isNullOrBlank() }, + "resuming with the human decision should produce a final answer", + ) + } + + /** + * Drives the confirmation shape against the live model: turn 1 must gate behind + * [CONFIRMATION_CALL] without running the tool body, and approving on turn 2 must run it. + */ + private suspend fun assertConfirmationGatesThenRuns(rootAgent: BaseAgent) { + val runner = InMemoryRunner(resumableApp(rootAgent)) + val turn1 = + runner + .runAsync( + userId = "u", + sessionId = "s", + newMessage = Content.fromText("user", "Delete the file /tmp/old.log"), + runConfig = RunConfig(maxLlmCalls = 6), + ) + .toList() + + val confirmCall = + assertNotNull( + turn1 + .flatMap { it.content?.parts.orEmpty() } + .mapNotNull { it.functionCall } + .firstOrNull { it.name == CONFIRMATION_CALL }, + "a requireConfirmation tool must gate behind $CONFIRMATION_CALL", + ) + // The framework emits a placeholder deleteFile response carrying the confirmation request; the + // tool BODY must not have run, and only the body reports status=DELETED. + val gated = + assertNotNull( + responseFor(turn1, "deleteFile"), + "the framework should emit a placeholder deleteFile response", + ) + assertTrue( + gated["status"] != "DELETED", + "the gated tool's body must not run before it is confirmed", + ) + + val turn2 = + runner + .runAsync( + userId = "u", + sessionId = "s", + newMessage = + functionResponseTurn(CONFIRMATION_CALL, confirmCall.id, mapOf("confirmed" to true)), + runConfig = RunConfig(maxLlmCalls = 6), + ) + .toList() + + assertEquals( + "DELETED", + responseFor(turn2, "deleteFile")?.get("status"), + "approving the confirmation should actually run the gated Java tool's body", + ) + } + + private companion object { + /** + * The rolling "latest" alias, so these tests track the current model, not a pinned snapshot. + */ + private const val MODEL = "gemini-flash-latest" + + private const val CONFIRMATION_CALL = "adk_request_confirmation" + + private fun tool(method: String) = FunctionTool.create(LiveInteropTools::class.java, method) + + /** + * Resumability is what makes a flow pause on a pending long-running call instead of looping. + */ + private fun resumableApp(agent: BaseAgent) = + App( + appName = "live", + rootAgent = agent, + resumabilityConfig = ResumabilityConfig(isResumable = true), + ) + + private fun responseNames(events: List): List = + events.flatMap { it.content?.parts.orEmpty() }.mapNotNull { it.functionResponse?.name } + + /** The payload of the last response named [name], or null if the tool produced none. */ + private fun responseFor(events: List, name: String): Map? = + events + .flatMap { it.content?.parts.orEmpty() } + .mapNotNull { it.functionResponse } + .lastOrNull { it.name == name } + ?.response + + private fun pendingLongRunningCall(events: List, name: String): FunctionCall? { + val ids = events.flatMap { it.longRunningToolIds }.toSet() + return events + .flatMap { it.content?.parts.orEmpty() } + .mapNotNull { it.functionCall } + .firstOrNull { it.name == name && it.id in ids } + } + + /** A user turn carrying the awaited [FunctionResponse] for a paused call. */ + private fun functionResponseTurn(name: String, id: String?, response: Map) = + Content( + role = "user", + parts = + listOf( + Part(functionResponse = FunctionResponse(name = name, id = id, response = response)) + ), + ) + } +} diff --git a/tokt/src/test/java/com/google/adk/tokt/KtRunnerInteropTest.kt b/tokt/src/test/java/com/google/adk/tokt/KtRunnerInteropTest.kt new file mode 100644 index 000000000..c3e4d30e2 --- /dev/null +++ b/tokt/src/test/java/com/google/adk/tokt/KtRunnerInteropTest.kt @@ -0,0 +1,2164 @@ +/* + * 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.BaseAgent as JavaBaseAgent +import com.google.adk.agents.CallbackContext as JavaCallbackContext +import com.google.adk.agents.InvocationContext as JavaInvocationContext +import com.google.adk.agents.ReadonlyContext as JavaReadonlyContext +import com.google.adk.agents.RunConfig as JavaRunConfig +import com.google.adk.artifacts.InMemoryArtifactService as JavaInMemoryArtifactService +import com.google.adk.events.Event as JavaEvent +import com.google.adk.events.EventActions as JavaEventActions +import com.google.adk.events.EventCompaction as JavaEventCompaction +import com.google.adk.kt.agents.LlmAgent as KtLlmAgent +import com.google.adk.kt.agents.RunConfig as KtRunConfig +import com.google.adk.kt.agents.StreamingMode as KtStreamingMode +import com.google.adk.kt.apps.App as KtApp +import com.google.adk.kt.callbacks.AfterModelCallback as KtAfterModelCallback +import com.google.adk.kt.models.LlmResponse as KtLlmResponse +import com.google.adk.kt.runners.InMemoryRunner as KtInMemoryRunner +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 +import com.google.adk.kt.types.Blob as KtBlob +import com.google.adk.kt.types.Content as KtContent +import com.google.adk.kt.types.FileData as KtFileData +import com.google.adk.kt.types.FunctionCallingConfig as KtFunctionCallingConfig +import com.google.adk.kt.types.FunctionResponse as KtFunctionResponse +import com.google.adk.kt.types.GenerateContentConfig as KtConfig +import com.google.adk.kt.types.GenerationConfigRoutingConfig as KtRoutingConfig +import com.google.adk.kt.types.GenerationConfigRoutingConfigManualRoutingMode as KtManualRoutingMode +import com.google.adk.kt.types.GoogleMaps as KtGoogleMaps +import com.google.adk.kt.types.GoogleSearch as KtGoogleSearch +import com.google.adk.kt.types.HarmBlockThreshold as KtHarmBlockThreshold +import com.google.adk.kt.types.HarmCategory as KtHarmCategory +import com.google.adk.kt.types.MediaResolution as KtMediaResolution +import com.google.adk.kt.types.Part as KtPart +import com.google.adk.kt.types.PartialArgValue as KtPartialArgValue +import com.google.adk.kt.types.Retrieval as KtRetrieval +import com.google.adk.kt.types.SafetySetting as KtSafetySetting +import com.google.adk.kt.types.Schema as KtSchema +import com.google.adk.kt.types.ServiceTier as KtServiceTier +import com.google.adk.kt.types.ThinkingConfig as KtThinkingConfig +import com.google.adk.kt.types.ThinkingLevel as KtThinkingLevel +import com.google.adk.kt.types.Tool as KtTool +import com.google.adk.kt.types.ToolConfig as KtToolConfig +import com.google.adk.kt.types.Type as KtType +import com.google.adk.kt.types.UrlContext as KtUrlContext +import com.google.adk.kt.types.VertexAISearch as KtVertexAISearch +import com.google.adk.kt.types.VertexAISearchDataStoreSpec as KtVertexAISearchDataStoreSpec +import com.google.adk.kt.types.VertexRagStore as KtVertexRagStore +import com.google.adk.kt.types.VertexRagStoreRagResource as KtVertexRagStoreRagResource +import com.google.adk.memory.InMemoryMemoryService as JavaInMemoryMemoryService +import com.google.adk.models.BaseLlm as JavaBaseLlm +import com.google.adk.models.BaseLlmConnection as JavaBaseLlmConnection +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.sessions.BaseSessionService as JavaBaseSessionService +import com.google.adk.sessions.GetSessionConfig as JavaGetSessionConfig +import com.google.adk.sessions.InMemorySessionService as JavaInMemorySessionService +import com.google.adk.sessions.Session as JavaSession +import com.google.adk.sessions.State as JavaState +import com.google.adk.tools.BaseTool as JavaBaseTool +import com.google.adk.tools.BaseToolset as JavaBaseToolset +import com.google.adk.tools.ToolContext as JavaToolContext +import com.google.errorprone.annotations.CanIgnoreReturnValue +import com.google.genai.types.Content as GenaiContent +import com.google.genai.types.CustomMetadata as GenaiCustomMetadata +import com.google.genai.types.ExecutableCode as GenaiExecutableCode +import com.google.genai.types.FinishReason as GenaiFinishReason +import com.google.genai.types.FunctionCall as GenaiFunctionCall +import com.google.genai.types.FunctionDeclaration as GenaiFunctionDeclaration +import com.google.genai.types.GenerateContentResponseUsageMetadata as GenaiUsageMetadata +import com.google.genai.types.GroundingChunk as GenaiGroundingChunk +import com.google.genai.types.GroundingChunkRetrievedContext as GenaiGroundingChunkRetrievedContext +import com.google.genai.types.GroundingChunkWeb as GenaiGroundingChunkWeb +import com.google.genai.types.GroundingMetadata as GenaiGroundingMetadata +import com.google.genai.types.GroundingSupport as GenaiGroundingSupport +import com.google.genai.types.MediaModality as GenaiMediaModality +import com.google.genai.types.ModalityTokenCount as GenaiModalityTokenCount +import com.google.genai.types.Part as GenaiPart +import com.google.genai.types.PartialArg as GenaiPartialArg +import com.google.genai.types.RetrievalMetadata as GenaiRetrievalMetadata +import com.google.genai.types.Schema as GenaiSchema +import com.google.genai.types.SearchEntryPoint as GenaiSearchEntryPoint +import com.google.genai.types.Segment as GenaiSegment +import com.google.genai.types.TrafficType as GenaiTrafficType +import com.google.genai.types.VideoMetadata as GenaiVideoMetadata +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Maybe +import io.reactivex.rxjava3.core.Single +import java.util.Optional +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import kotlin.jvm.optionals.getOrNull +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking + +/** Runs one user turn on the shared test session and collects the events it emits. */ +@CanIgnoreReturnValue +private suspend fun KtInMemoryRunner.turn(text: String = "go") = + runAsync(userId = "u", sessionId = "s", newMessage = KtContent.fromText("user", text)).toList() + +/** + * Exercises the Kotlin (engine) [KtInMemoryRunner] driving a Kotlin agent whose components are ADK + * Java components adapted via [JavaAdkToKt] (forward interop). + */ +class KtRunnerInteropTest { + + /** A Java tool that echoes its args; used by the engine runner via [JavaAdkToKt.asKtTool]. */ + private class JavaEchoTool : JavaBaseTool("java_echo", "echoes args") { + // A parameter schema covering every SchemaCodec facet: object + properties, nested array items, + // an enum, and required. + override fun declaration(): Optional = + Optional.of( + GenaiFunctionDeclaration.builder() + .name("java_echo") + .description("echoes args") + .parameters( + GenaiSchema.builder() + .type("OBJECT") + .properties( + mapOf( + "text" to + GenaiSchema.builder().type("STRING").description("text to echo").build(), + "tags" to + GenaiSchema.builder() + .type("ARRAY") + .items(GenaiSchema.builder().type("STRING").build()) + .build(), + "mode" to + GenaiSchema.builder().type("STRING").enum_(listOf("upper", "lower")).build(), + ) + ) + .required(listOf("text")) + .description("echo arguments") + .build() + ) + .build() + ) + + @JvmSuppressWildcards + override fun runAsync( + args: Map, + toolContext: JavaToolContext, + ): Single> = Single.just(mapOf("echoed" to (args["text"] ?: ""))) + } + + /** A Java tool provided by a [JavaBaseToolset]; used via [JavaAdkToKt.asKtToolset]. */ + private class JavaToolsetTool : JavaBaseTool("toolset_tool", "from a toolset") { + override fun declaration(): Optional = + Optional.of(GenaiFunctionDeclaration.builder().name("toolset_tool").build()) + + @JvmSuppressWildcards + override fun runAsync( + args: Map, + toolContext: JavaToolContext, + ): Single> = Single.just(mapOf("from" to "toolset")) + } + + /** A Java toolset exposing a single [JavaToolsetTool]. */ + private class JavaEchoToolset : JavaBaseToolset { + override fun getTools(readonlyContext: JavaReadonlyContext?): Flowable { + // Read through the readonly-context view so its accessors are exercised, mirroring a real + // dynamic toolset that filters its tools by the invocation context. + readonlyContext?.let { ctx -> + val reads = + listOf( + ctx.userContent(), + ctx.invocationId(), + ctx.branch(), + ctx.agentName(), + ctx.userId(), + ctx.sessionId(), + ctx.events(), + ctx.state(), + runCatching { ctx.invocationContext() }.isFailure, + ) + // Pin the values, not just the fact that the accessors returned: a toolset filtering by + // context is the reason ReadonlyContext is bridged at all. + check(ctx.agentName() == "a") { "unexpected agentName: ${ctx.agentName()}" } + check(ctx.userId() == "u") { "unexpected userId: ${ctx.userId()}" } + check(ctx.sessionId() == "s") { "unexpected sessionId: ${ctx.sessionId()}" } + check(reads.last() == true) { "invocationContext() should be unavailable here" } + } + return Flowable.just(JavaToolsetTool()) + } + + override fun close() {} + } + + /** + * A Java tool that gates on human confirmation: it requests confirmation when none is present, + * and proceeds once the resumed call carries one. Exercises the interop confirmation bridge. + */ + private class JavaConfirmTool : JavaBaseTool("java_confirm", "requires confirmation") { + override fun declaration(): Optional = + Optional.of(GenaiFunctionDeclaration.builder().name("java_confirm").build()) + + @JvmSuppressWildcards + override fun runAsync( + args: Map, + toolContext: JavaToolContext, + ): Single> { + val confirmation = toolContext.toolConfirmation() + if (confirmation.isEmpty) { + toolContext.requestConfirmation("please approve java_confirm") + return Single.just(mapOf("status" to "pending")) + } + return Single.just( + mapOf("status" to if (confirmation.get().confirmed()) "confirmed" else "rejected") + ) + } + } + + /** + * A Java tool that mutates its [JavaToolContext.actions] in place (not via `setActions`) to set + * every control-flow signal, exercising the live [KtEventActionsToJavaView] write-through. + */ + private class JavaControlFlowTool : + JavaBaseTool("java_control_flow", "sets control-flow actions") { + override fun declaration(): Optional = + Optional.of(GenaiFunctionDeclaration.builder().name("java_control_flow").build()) + + @JvmSuppressWildcards + override fun runAsync( + args: Map, + toolContext: JavaToolContext, + ): Single> { + toolContext.actions().setEscalate(true) + toolContext.actions().setSkipSummarization(true) + toolContext.actions().setEndOfAgent(true) + toolContext.actions().setTransferToAgent("b") + return Single.just(mapOf("ok" to true)) + } + } + + /** + * A Java tool that writes to and removes from [JavaToolContext.state], exercising the Java -> + * Kotlin removal-sentinel translation: it keeps one key and set-then-removes another. + */ + private class JavaStateMutatingTool : JavaBaseTool("java_state_mutator", "mutates state") { + override fun declaration(): Optional = + Optional.of(GenaiFunctionDeclaration.builder().name("java_state_mutator").build()) + + @JvmSuppressWildcards + override fun runAsync( + args: Map, + toolContext: JavaToolContext, + ): Single> { + toolContext.state()["kept"] = "yes" + toolContext.state()["gone"] = "temp" + val removed = toolContext.state().remove("gone") + return Single.just(mapOf("removed" to (removed ?: "none"))) + } + } + + /** A Java tool that echoes its running agent's name, exercising ToolContext.agentName(). */ + private class JavaAgentNameTool : JavaBaseTool("java_agent_name", "reports the agent name") { + override fun declaration(): Optional = + Optional.of(GenaiFunctionDeclaration.builder().name("java_agent_name").build()) + + @JvmSuppressWildcards + override fun runAsync( + args: Map, + toolContext: JavaToolContext, + ): Single> = Single.just(mapOf("agent" to toolContext.agentName())) + } + + /** + * A Java tool that replaces its actions wholesale via `setActions(...)`, the way a Java tool that + * builds its own `EventActions` does, rather than mutating the live ones. + */ + private class JavaActionsReplacingTool : + JavaBaseTool("replace_actions", "replaces its EventActions wholesale") { + override fun declaration(): Optional = + Optional.of(GenaiFunctionDeclaration.builder().name("replace_actions").build()) + + @JvmSuppressWildcards + override fun runAsync( + args: Map, + toolContext: JavaToolContext, + ): Single> { + toolContext.setActions( + JavaEventActions.builder() + .stateDelta(mutableMapOf("replaced_state" to "from_actions")) + .artifactDelta(mutableMapOf("replaced_artifact.txt" to 7)) + .build() + ) + return Single.just(mapOf("ok" to true)) + } + } + + /** + * A Java plugin that writes and removes state from the agent-level callbacks. Both callbacks are + * covered because each reconciles the removal sentinel independently. + */ + private class AgentStateRemovingPlugin : JavaBasePlugin("agent_state_removing_plugin") { + override fun beforeAgentCallback( + agent: JavaBaseAgent, + callbackContext: JavaCallbackContext, + ): Maybe = removeIn(callbackContext, "before") + + override fun afterAgentCallback( + agent: JavaBaseAgent, + callbackContext: JavaCallbackContext, + ): Maybe = removeIn(callbackContext, "after") + + private fun removeIn(context: JavaCallbackContext, phase: String): Maybe { + context.state()["agent_kept_$phase"] = "yes" + context.state()["agent_gone_$phase"] = "temp" + val unused = context.state().remove("agent_gone_$phase") + return Maybe.empty() + } + } + + /** A Java plugin whose afterTool callback removes a state key via the live tool context. */ + private class ToolStateRemovingPlugin : JavaBasePlugin("tool_state_removing_plugin") { + @JvmSuppressWildcards + override fun afterToolCallback( + tool: JavaBaseTool, + toolArgs: Map, + toolContext: JavaToolContext, + result: Map, + ): Maybe> { + toolContext.state()["kept3"] = "yes" + toolContext.state()["gone3"] = "temp" + val removed = toolContext.state().remove("gone3") + return Maybe.just(mapOf("removed" to (removed ?: "none"))) + } + } + + /** + * A Java tool driving the three `EventActions` mutators the live view inherits rather than + * overrides. Each writes a private field on the Java base class that the overridden getters do + * not read, so a view missing the override loses the write with no error. + */ + private class JavaInheritedActionMutatorsTool : + JavaBaseTool("inherited_mutators", "exercises inherited EventActions mutators") { + override fun declaration(): Optional = + Optional.of(GenaiFunctionDeclaration.builder().name("inherited_mutators").build()) + + @JvmSuppressWildcards + override fun runAsync( + args: Map, + toolContext: JavaToolContext, + ): Single> { + toolContext.actions().stateDelta()["doomed"] = "temp" + toolContext.actions().removeStateByKey("doomed") + toolContext.actions().setArtifactDelta(mapOf("replaced.txt" to 9)) + toolContext + .actions() + .setCompaction( + JavaEventCompaction.builder() + .startTimestamp(1L) + .endTimestamp(2L) + .compactedContent(modelText("summary")) + .build() + ) + return Single.just(mapOf("ok" to true)) + } + } + + /** A Java plugin that records the run config it is handed. */ + private class RunConfigCapturingJavaPlugin : JavaBasePlugin("run_config_plugin") { + var seen: JavaRunConfig? = null + + override fun beforeRunCallback(invocationContext: JavaInvocationContext): Maybe { + seen = invocationContext.runConfig() + return Maybe.empty() + } + } + + /** A Java plugin that counts how many invocations the runner started. */ + private class CountingJavaPlugin : JavaBasePlugin("counting_plugin") { + var beforeRunCount = 0 + + override fun beforeRunCallback(invocationContext: JavaInvocationContext): Maybe { + beforeRunCount++ + return Maybe.empty() + } + } + + /** A Java plugin that tries to re-scope the branch of the invocation it is handed. */ + private class BranchSettingJavaPlugin : JavaBasePlugin("branch_setting_plugin") { + var error: Throwable? = null + + override fun beforeRunCallback(invocationContext: JavaInvocationContext): Maybe { + try { + invocationContext.branch("custom-branch") + } catch (e: UnsupportedOperationException) { + error = e + } + return Maybe.empty() + } + } + + /** + * A Java plugin that inspects the engine agent it is handed: it records the sub-agent names (the + * wrapped tree must be visible) and tries to run it (which must fail, since the view is + * inspection-only). It captures the error and lets the run go on. + */ + private class AgentInspectingJavaPlugin : JavaBasePlugin("agent_inspecting_plugin") { + var runError: Throwable? = null + var subAgentNames: List = emptyList() + + override fun beforeRunCallback(invocationContext: JavaInvocationContext): Maybe { + val agent = invocationContext.agent() + subAgentNames = agent.subAgents().map { it.name() } + return agent + .runAsync(invocationContext) + .count() + .flatMapMaybe { Maybe.empty() } + .onErrorComplete { e -> + runError = e + true + } + } + } + + /** + * A Java plugin that saves an artifact from its model callback, exercising the artifact service + * wired into the callback context. + */ + private class ArtifactSavingJavaPlugin : JavaBasePlugin("artifact_saving_plugin") { + var saved = false + var saveError: Throwable? = null + + override fun beforeModelCallback( + callbackContext: JavaCallbackContext, + llmRequestBuilder: JavaLlmRequest.Builder, + ): Maybe = + callbackContext + .saveArtifact("note.txt", GenaiPart.fromText("hi")) + .doOnComplete { saved = true } + .toMaybe() + .onErrorComplete { e -> + saveError = e + true + } + } + + /** A Java model that replays a fixed sequence of responses, one per LLM step. */ + private class SequentialJavaModel(private val turns: List) : + JavaBaseLlm("java-model") { + private var step = 0 + + /** Every request the Kotlin engine converted for this model, for codec assertions. */ + val requests = CopyOnWriteArrayList() + + override fun generateContent( + llmRequest: JavaLlmRequest, + stream: Boolean, + ): Flowable { + requests.add(llmRequest) + val content = turns[step.coerceAtMost(turns.size - 1)] + step++ + // Attach finish reason + usage metadata so LlmResponseCodec / UsageMetadataCodec are covered. + return Flowable.just( + JavaLlmResponse.builder() + .content(content) + .finishReason(GenaiFinishReason("STOP")) + .usageMetadata( + GenaiUsageMetadata.builder() + .promptTokenCount(3) + .candidatesTokenCount(5) + .totalTokenCount(8) + .thoughtsTokenCount(2) + .toolUsePromptTokenCount(1) + .cachedContentTokenCount(4) + .toolUsePromptTokensDetails( + listOf( + GenaiModalityTokenCount.builder() + .modality(GenaiMediaModality.Known.TEXT) + .tokenCount(1) + .build() + ) + ) + .trafficType(GenaiTrafficType.Known.ON_DEMAND) + .promptTokensDetails( + listOf( + GenaiModalityTokenCount.builder() + .modality(GenaiMediaModality("TEXT")) + .tokenCount(3) + .build() + ) + ) + .build() + ) + .customMetadata( + listOf( + GenaiCustomMetadata.builder().key("tag").stringValue("v1").build(), + GenaiCustomMetadata.builder().key("score").numericValue(0.5f).build(), + ) + ) + .build() + ) + } + + override fun connect(llmRequest: JavaLlmRequest): JavaBaseLlmConnection = + throw UnsupportedOperationException() + } + + @Test + fun javaAdkToKt_convertsEntireCollections() { + // Tools: order preserved, each Java tool wrapped as a Kotlin tool. + val ktTools = JavaAdkToKt.asKtTools(listOf(JavaEchoTool(), JavaConfirmTool())) + assertEquals(listOf("java_echo", "java_confirm"), ktTools.map { it.name }) + + // Toolsets and plugins: size preserved. + assertEquals(1, JavaAdkToKt.asKtToolsets(listOf(JavaEchoToolset())).size) + assertEquals(1, JavaAdkToKt.asKtPlugins(listOf(CountingJavaPlugin())).size) + + // Empty collections convert to empty. + assertTrue(JavaAdkToKt.asKtTools(emptyList()).isEmpty()) + } + + @Test + fun ktRunner_stateDeltaWrittenToAnAdaptedJavaSessionService_mapsOnlyTheRemovalSentinel() = + runBlocking { + // Kotlin event -> Java conversion must translate the Kotlin removal sentinel to the Java one + // and leave every other value alone. Asserting both halves: translating unconditionally + // would turn an ordinary value into a deletion. + val javaSessions = JavaInMemorySessionService() + val agent = + KtLlmAgent( + name = "a", + model = + JavaAdkToKt.asKtModel( + SequentialJavaModel( + listOf(modelFunctionCall("java_echo", mapOf("text" to "hi")), modelText("done")) + ) + ), + tools = listOf(JavaAdkToKt.asKtTool(JavaEchoTool())), + ) + val runner = + KtInMemoryRunner( + app = + KtApp( + appName = "app", + rootAgent = agent, + plugins = listOf(JavaAdkToKt.asKtPlugin(ToolStateRemovingPlugin())), + ), + sessionService = JavaAdkToKt.asKtSessionService(javaSessions), + ) + + runner.turn() + + val stored = + javaSessions.getSession("app", "u", "s", Optional.empty()).blockingGet() + ?: fail("the adapted Java service should hold the session") + val deltas = stored.events().map { it.actions().stateDelta() } + assertEquals( + "yes", + deltas.firstNotNullOfOrNull { it["kept3"] }, + "an ordinary value must cross unchanged, not become a deletion", + ) + assertSame( + JavaState.REMOVED, + deltas.firstNotNullOfOrNull { it["gone3"] }, + "the Kotlin removal sentinel must be translated to the Java one", + ) + } + + @Test + fun ktRunner_modelPartCarryingOnlyAnUnmappedKind_isDropped() = runBlocking { + // executableCode has no Kotlin counterpart, so a part carrying only it must be dropped rather + // than surviving as an empty part. This is what makes the primary-payload branches in + // PartCodec.fromJava observable: a part's thought/metadata fields are re-attached afterwards + // either way, so the drop is the only externally visible difference. + val model = + object : JavaBaseLlm("java-model") { + override fun generateContent( + llmRequest: JavaLlmRequest, + stream: Boolean, + ): Flowable = + Flowable.just( + JavaLlmResponse.builder() + .content( + GenaiContent.builder() + .role("model") + .parts( + listOf( + GenaiPart.builder() + .executableCode(GenaiExecutableCode.builder().code("print(1)").build()) + .build(), + GenaiPart.builder().text("done").build(), + ) + ) + .build() + ) + .build() + ) + + override fun connect(llmRequest: JavaLlmRequest): JavaBaseLlmConnection = + throw UnsupportedOperationException() + } + val agent = KtLlmAgent(name = "a", model = JavaAdkToKt.asKtModel(model)) + val runner = KtInMemoryRunner(agent, appName = "app") + + val events = runner.turn() + + val parts = events.firstNotNullOfOrNull { it.content?.parts?.takeIf { p -> p.isNotEmpty() } } + assertEquals( + listOf("done"), + parts?.map { it.text }, + "the executableCode-only part should be dropped, leaving just the text part", + ) + } + + @Test + fun ktRunner_javaPluginReadsRunConfig_seesTheRunsOwnSettings() = runBlocking { + // The Java and Kotlin defaults coincide (NONE / 500 / empty), so only a non-default config + // distinguishes a bridged run config from the Java builder's default. + val plugin = RunConfigCapturingJavaPlugin() + val agent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(SequentialJavaModel(listOf(modelText("done")))), + ) + val runner = + KtInMemoryRunner( + app = + KtApp( + appName = "app", + rootAgent = agent, + plugins = listOf(JavaAdkToKt.asKtPlugin(plugin)), + ) + ) + + runner + .runAsync( + userId = "u", + sessionId = "s", + newMessage = KtContent.fromText("user", "go"), + runConfig = + KtRunConfig( + streamingMode = KtStreamingMode.SSE, + maxLlmCalls = 7, + customMetadata = mapOf("tenant" to "acme"), + ), + ) + .toList() + + val seen = assertNotNull(plugin.seen, "the plugin should have been handed a run config") + assertEquals(JavaRunConfig.StreamingMode.SSE, seen.streamingMode(), "streamingMode") + assertEquals(7, seen.maxLlmCalls(), "maxLlmCalls") + assertEquals>( + mapOf("tenant" to "acme"), + seen.customMetadata(), + "customMetadata", + ) + } + + @Test + fun ktRunner_javaToolUsingInheritedActionMutators_writesReachTheKotlinSide() = runBlocking { + val model = + SequentialJavaModel( + listOf(modelFunctionCall("inherited_mutators", emptyMap()), modelText("done")) + ) + val agent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(model), + tools = listOf(JavaAdkToKt.asKtTool(JavaInheritedActionMutatorsTool())), + ) + val runner = KtInMemoryRunner(agent, appName = "app") + + val events = runner.turn() + + // removeStateByKey must land as the *Kotlin* sentinel, or the engine will not delete the key. + assertEquals( + KtState.REMOVED, + events.firstNotNullOfOrNull { it.actions.stateDelta["doomed"] }, + "removeStateByKey should reach the Kotlin delta as its removal sentinel", + ) + assertEquals( + 9, + events.firstNotNullOfOrNull { it.actions.artifactDelta["replaced.txt"] }, + "setArtifactDelta should reach the Kotlin delta", + ) + assertEquals( + 1L to 2L, + events.firstNotNullOfOrNull { e -> + e.actions.compaction?.let { it.startTimestamp to it.endTimestamp } + }, + "setCompaction should reach the Kotlin actions", + ) + } + + @Test + fun ktRunner_javaPluginSetsBranch_throwsRatherThanSilentlyIgnoringIt() = runBlocking { + // The Kotlin branch is immutable, so the write cannot be honoured. Failing loudly is the + // point: a silent no-op would leave the plugin believing it had re-scoped the invocation. + val plugin = BranchSettingJavaPlugin() + val agent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(SequentialJavaModel(listOf(modelText("done")))), + ) + val runner = + KtInMemoryRunner( + app = + KtApp( + appName = "app", + rootAgent = agent, + plugins = listOf(JavaAdkToKt.asKtPlugin(plugin)), + ) + ) + + val events = runner.turn() + + assertTrue( + plugin.error is UnsupportedOperationException, + "setting the branch should be rejected, got ${plugin.error}", + ) + assertTrue(events.isNotEmpty(), "the run itself should still complete") + } + + @Test + fun ktRunner_runsAgent_withJavaModelAndJavaTool() = runBlocking { + val model = + SequentialJavaModel( + listOf( + modelFunctionCall("java_echo", mapOf("text" to "hi")), + modelText("done from java model"), + ) + ) + val agent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(model), + tools = listOf(JavaAdkToKt.asKtTool(JavaEchoTool())), + ) + val runner = KtInMemoryRunner(agent, appName = "app") + + val events = runner.turn() + + assertTrue( + events.any { e -> e.content?.parts?.any { it.text == "done from java model" } == true } + ) + assertTrue( + events.any { e -> e.content?.parts?.any { it.functionResponse?.name == "java_echo" } == true } + ) + + // FunctionDeclarationCodec + SchemaCodec: the tool's declaration made the Java -> Kotlin -> + // Java round trip intact. Asserting the schema, not just that the call happened, is what makes + // deleting a field mapping fail this test. + val declared = + assertNotNull( + model.requests + .first() + .config() + .getOrNull() + ?.tools() + ?.getOrNull() + ?.flatMap { it.functionDeclarations().getOrNull().orEmpty() } + ?.firstOrNull { it.name().getOrNull() == "java_echo" }, + "the Java tool's declaration should reach the Java model", + ) + val params = assertNotNull(declared.parameters().getOrNull(), "parameters") + assertEquals("OBJECT", params.type().getOrNull()?.toString(), "root schema type") + assertEquals(listOf("text"), params.required().getOrNull(), "required") + val props = assertNotNull(params.properties().getOrNull(), "properties") + assertEquals("text to echo", props["text"]?.description()?.getOrNull(), "nested description") + assertEquals( + "STRING", + props["tags"]?.items()?.getOrNull()?.type()?.getOrNull()?.toString(), + "array item type", + ) + assertEquals(listOf("upper", "lower"), props["mode"]?.enum_()?.getOrNull(), "enum values") + } + + @Test + fun ktRunner_wrappedAgentIsInspectableButNotRunnable() = runBlocking { + val plugin = AgentInspectingJavaPlugin() + val subAgent = + KtLlmAgent( + name = "b", + model = JavaAdkToKt.asKtModel(SequentialJavaModel(listOf(modelText("sub")))), + ) + val agent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(SequentialJavaModel(listOf(modelText("hello")))), + subAgents = listOf(subAgent), + ) + val runner = + KtInMemoryRunner( + app = + KtApp( + appName = "app", + rootAgent = agent, + plugins = listOf(JavaAdkToKt.asKtPlugin(plugin)), + ) + ) + + val events = runner.turn("hi") + + // Inspection: the wrapped agent exposes the real sub-agent tree. + assertEquals(listOf("b"), plugin.subAgentNames) + // Execution: the wrapped agent is inspection-only, so running it must fail. + assertTrue( + plugin.runError is UnsupportedOperationException, + "running the wrapped agent should be unsupported, got ${plugin.runError}", + ) + // The run itself still completes normally. + assertTrue(events.any { e -> e.content?.parts?.any { it.text == "hello" } == true }) + } + + @Test + fun ktRunner_pluginCallbackCanUseArtifactService() = runBlocking { + val plugin = ArtifactSavingJavaPlugin() + val agent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(SequentialJavaModel(listOf(modelText("hi")))), + ) + val runner = + KtInMemoryRunner( + app = + KtApp( + appName = "app", + rootAgent = agent, + plugins = listOf(JavaAdkToKt.asKtPlugin(plugin)), + ), + artifactService = JavaAdkToKt.asKtArtifactService(JavaInMemoryArtifactService()), + ) + + runner.turn("hi") + + // The callback context wires the artifact service, so saving from a model callback succeeds + // rather than failing with "Artifact service is not initialized". + assertTrue( + plugin.saved, + "saveArtifact in a model callback should complete; error=${plugin.saveError}", + ) + // Completing is not enough: the write must have landed in the engine's own artifact service, + // not in some second service the adapter created for the callback context. + assertEquals( + "hi", + runner.artifactService!!.loadArtifact(KtSessionKey("app", "u", "s"), "note.txt")?.text, + "an artifact saved from a Java callback must be readable from the engine's service", + ) + } + + @Test + fun ktRunner_drivesEveryJavaAdapter_endToEnd() = runBlocking { + // The real backing services are ADK Java; the runner sees them through the forward adapters. + val javaSessions = JavaInMemorySessionService() + val javaArtifacts = JavaInMemoryArtifactService() + val javaMemory = JavaInMemoryMemoryService() + val plugin = CountingJavaPlugin() + + val model = + SequentialJavaModel( + listOf( + modelFunctionCall("java_echo", mapOf("text" to "hi")), + modelFunctionCall("toolset_tool", emptyMap()), + modelText("done from java model"), + ) + ) + val agent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(model), + tools = listOf(JavaAdkToKt.asKtTool(JavaEchoTool())), + toolsets = listOf(JavaAdkToKt.asKtToolset(JavaEchoToolset())), + ) + val runner = + KtInMemoryRunner( + app = + KtApp( + appName = "app", + rootAgent = agent, + plugins = listOf(JavaAdkToKt.asKtPlugin(plugin)), + ), + sessionService = JavaAdkToKt.asKtSessionService(javaSessions), + artifactService = JavaAdkToKt.asKtArtifactService(javaArtifacts), + memoryService = JavaAdkToKt.asKtMemoryService(javaMemory), + ) + + val events = runner.turn() + + // Model adapter: the final model text was produced. + assertTrue( + events.any { e -> e.content?.parts?.any { it.text == "done from java model" } == true }, + "expected final model text", + ) + // Tool adapter: the standalone Java tool ran. + assertTrue( + events.any { e -> + e.content?.parts?.any { it.functionResponse?.name == "java_echo" } == true + }, + "expected standalone tool to run", + ) + // Toolset adapter: the Java toolset's tool ran. + assertTrue( + events.any { e -> + e.content?.parts?.any { it.functionResponse?.name == "toolset_tool" } == true + }, + "expected toolset tool to run", + ) + // Plugin adapter: the Java plugin observed the run. + assertTrue(plugin.beforeRunCount >= 1, "expected plugin beforeRun to fire") + + // Session-service adapter: the runner created and persisted the session through it. + val key = KtSessionKey("app", "u", "s") + val session = runner.sessionService.getSession(key)!! + assertTrue( + session.events.isNotEmpty(), + "expected session persisted via adapted session service", + ) + + // Artifact-service adapter: save + load round-trip on the runner's adapted service. + val version = runner.artifactService!!.saveArtifact(key, "note.txt", KtPart(text = "v1")) + assertEquals(0, version, "the first save of a filename is version 0") + val loaded = runner.artifactService!!.loadArtifact(key, "note.txt") + assertEquals("v1", loaded?.text) + + // Memory-service adapter: index the run's text events (keyword search only handles text + // parts), then keyword-search the model's reply. + val textEvents = + session.events.filter { e -> + val parts = e.content?.parts + parts != null && parts.isNotEmpty() && parts.all { !it.text.isNullOrEmpty() } + } + runner.memoryService!!.addSessionToMemory(session.copy(events = textEvents.toMutableList())) + val hits = runner.memoryService!!.searchMemory("app", "u", "done") + assertTrue(hits.memories.isNotEmpty(), "expected memory search to hit via adapted service") + } + + @Test + fun ktRunner_richGenerateContentConfigAndParts_crossToTheJavaModel() = runBlocking { + val javaSessions = JavaInMemorySessionService() + val javaArtifacts = JavaInMemoryArtifactService() + val model = + SequentialJavaModel( + listOf( + GenaiContent.builder() + .role("model") + .parts( + GenaiPart.builder() + .functionCall( + GenaiFunctionCall.builder() + .name("java_echo") + .args(mapOf("text" to "hi")) + .willContinue(false) + .partialArgs( + listOf( + GenaiPartialArg.builder() + .stringValue("hi") + .jsonPath("$.text") + .willContinue(false) + .build() + ) + ) + .build() + ) + .build() + ) + .build(), + GenaiContent.builder() + .role("model") + .parts( + GenaiPart.builder() + .text("clip") + .videoMetadata( + GenaiVideoMetadata.builder() + .startOffset(java.time.Duration.ofSeconds(1)) + .endOffset(java.time.Duration.ofSeconds(2)) + .fps(24.0) + .build() + ) + .build() + ) + .build(), + modelText("done rich"), + ) + ) + // Rich generation config so GenerateContentConfigCodec.toJava covers its parameter branches. + val ktConfig = + KtConfig( + systemInstruction = KtContent.fromText("system", "be helpful"), + temperature = 0.5f, + topP = 0.9f, + topK = 40, + candidateCount = 1, + maxOutputTokens = 256, + stopSequences = listOf("STOP"), + responseMimeType = "text/plain", + responseSchema = KtSchema(type = KtType.STRING), + labels = mapOf("env" to "test"), + presencePenalty = 0.1f, + frequencyPenalty = 0.2f, + responseLogprobs = true, + mediaResolution = KtMediaResolution.MEDIA_RESOLUTION_LOW, + serviceTier = KtServiceTier.STANDARD, + routingConfig = + KtRoutingConfig(manualMode = KtManualRoutingMode(modelName = "router-model")), + toolConfig = + KtToolConfig( + functionCallingConfig = + KtFunctionCallingConfig( + allowedFunctionNames = listOf("java_echo"), + streamFunctionCallArguments = true, + ) + ), + safetySettings = + listOf( + KtSafetySetting( + category = KtHarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold = KtHarmBlockThreshold.BLOCK_ONLY_HIGH, + ) + ), + thinkingConfig = + KtThinkingConfig( + includeThoughts = true, + thinkingBudget = 128, + thinkingLevel = KtThinkingLevel.MEDIUM, + ), + tools = + listOf( + KtTool(googleSearch = KtGoogleSearch(excludeDomains = listOf("example.com"))), + KtTool(googleMaps = KtGoogleMaps(enableWidget = true)), + KtTool(urlContext = KtUrlContext()), + KtTool( + retrieval = + KtRetrieval( + vertexAiSearch = + KtVertexAISearch( + datastore = "ds", + engine = "eng", + filter = "f", + maxResults = 5, + dataStoreSpecs = + listOf(KtVertexAISearchDataStoreSpec(dataStore = "d", filter = "df")), + ) + ) + ), + KtTool( + retrieval = + KtRetrieval( + vertexRagStore = + KtVertexRagStore( + ragCorpora = listOf("corpora/1"), + ragResources = + listOf( + KtVertexRagStoreRagResource( + ragCorpus = "corpora/2", + ragFileIds = listOf("f1"), + ) + ), + similarityTopK = 3, + vectorDistanceThreshold = 0.7, + ) + ) + ), + ), + ) + // customMetadata rides on the LlmResponse and the engine does not copy it onto the event, so + // a callback is the only place it is observable. + val modelResponses = mutableListOf() + val agent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(model), + tools = listOf(JavaAdkToKt.asKtTool(JavaEchoTool())), + generateContentConfig = ktConfig, + afterModelCallbacks = + listOf( + KtAfterModelCallback { _, response -> + modelResponses += response + response + } + ), + ) + val runner = + KtInMemoryRunner( + app = KtApp(appName = "app", rootAgent = agent), + sessionService = JavaAdkToKt.asKtSessionService(javaSessions), + artifactService = JavaAdkToKt.asKtArtifactService(javaArtifacts), + ) + + val events = runner.turn() + assertTrue(events.isNotEmpty(), "expected the rich-config run to produce events") + + // GenerateContentConfigCodec: assert the fields actually crossed. Without this the whole codec + // could be deleted and the run would still succeed. + val javaConfig = + assertNotNull( + model.requests.first().config().getOrNull(), + "the converted request should carry a generation config", + ) + assertEquals(0.5f, javaConfig.temperature().getOrNull(), "temperature") + assertEquals(0.9f, javaConfig.topP().getOrNull(), "topP") + assertEquals(40.0f, javaConfig.topK().getOrNull(), "topK") + assertEquals(256, javaConfig.maxOutputTokens().getOrNull(), "maxOutputTokens") + assertEquals(listOf("STOP"), javaConfig.stopSequences().getOrNull(), "stopSequences") + assertEquals("text/plain", javaConfig.responseMimeType().getOrNull(), "responseMimeType") + assertEquals(mapOf("env" to "test"), javaConfig.labels().getOrNull(), "labels") + assertEquals(0.1f, javaConfig.presencePenalty().getOrNull(), "presencePenalty") + assertEquals(0.2f, javaConfig.frequencyPenalty().getOrNull(), "frequencyPenalty") + assertEquals(true, javaConfig.responseLogprobs().getOrNull(), "responseLogprobs") + assertTrue( + javaConfig.systemInstruction().getOrNull() != null, + "systemInstruction should cross as content", + ) + // Enums are asserted on toString() (the wire value), not knownEnum(): knownEnum() matches + // case-insensitively, so it would accept a wrongly-cased value. serviceTier is the one whose + // wire form differs from its constant name. + assertEquals( + "STRING", + javaConfig.responseSchema().getOrNull()?.type()?.getOrNull()?.toString(), + "responseSchema", + ) + assertEquals( + "MEDIA_RESOLUTION_LOW", + javaConfig.mediaResolution().getOrNull()?.toString(), + "mediaResolution", + ) + assertEquals("standard", javaConfig.serviceTier().getOrNull()?.toString(), "serviceTier") + assertEquals( + "router-model", + javaConfig.routingConfig().getOrNull()?.manualMode()?.getOrNull()?.modelName()?.getOrNull(), + "routingConfig manual model", + ) + val functionCalling = + assertNotNull( + javaConfig.toolConfig().getOrNull()?.functionCallingConfig()?.getOrNull(), + "toolConfig functionCallingConfig", + ) + assertEquals( + listOf("java_echo"), + functionCalling.allowedFunctionNames().getOrNull(), + "allowedFunctionNames", + ) + assertEquals( + true, + functionCalling.streamFunctionCallArguments().getOrNull(), + "streamFunctionCallArguments", + ) + val safetySetting = + assertNotNull(javaConfig.safetySettings().getOrNull()?.singleOrNull(), "safetySettings") + assertEquals( + "HARM_CATEGORY_HATE_SPEECH", + safetySetting.category().getOrNull()?.toString(), + "safetySetting category", + ) + assertEquals( + "BLOCK_ONLY_HIGH", + safetySetting.threshold().getOrNull()?.toString(), + "safetySetting threshold", + ) + val thinking = assertNotNull(javaConfig.thinkingConfig().getOrNull(), "thinkingConfig") + assertEquals(true, thinking.includeThoughts().getOrNull(), "includeThoughts") + assertEquals(128, thinking.thinkingBudget().getOrNull(), "thinkingBudget") + assertEquals("MEDIUM", thinking.thinkingLevel().getOrNull()?.toString(), "thinkingLevel") + + // The five built-in tool kinds: toolToJava / retrievalToJava / vertexRagStoreToJava. + val javaTools = assertNotNull(javaConfig.tools().getOrNull(), "tools") + assertEquals( + listOf("example.com"), + javaTools + .firstNotNullOfOrNull { it.googleSearch().getOrNull() } + ?.excludeDomains() + ?.getOrNull(), + "googleSearch excludeDomains", + ) + assertEquals( + true, + javaTools.firstNotNullOfOrNull { it.googleMaps().getOrNull() }?.enableWidget()?.getOrNull(), + "googleMaps enableWidget", + ) + assertTrue( + javaTools.any { it.urlContext().getOrNull() != null }, + "urlContext should cross as a tool", + ) + val vertexSearch = + assertNotNull( + javaTools.firstNotNullOfOrNull { + it.retrieval().getOrNull()?.vertexAiSearch()?.getOrNull() + }, + "vertexAiSearch retrieval", + ) + assertEquals("ds", vertexSearch.datastore().getOrNull(), "vertexAiSearch datastore") + assertEquals("eng", vertexSearch.engine().getOrNull(), "vertexAiSearch engine") + assertEquals("f", vertexSearch.filter().getOrNull(), "vertexAiSearch filter") + assertEquals(5, vertexSearch.maxResults().getOrNull(), "vertexAiSearch maxResults") + assertEquals( + listOf("d" to "df"), + vertexSearch.dataStoreSpecs().getOrNull()?.map { + it.dataStore().getOrNull() to it.filter().getOrNull() + }, + "vertexAiSearch dataStoreSpecs", + ) + val ragStore = + assertNotNull( + javaTools.firstNotNullOfOrNull { + it.retrieval().getOrNull()?.vertexRagStore()?.getOrNull() + }, + "vertexRagStore retrieval", + ) + assertEquals(listOf("corpora/1"), ragStore.ragCorpora().getOrNull(), "ragCorpora") + assertEquals( + listOf("corpora/2" to listOf("f1")), + ragStore.ragResources().getOrNull()?.map { + it.ragCorpus().getOrNull() to it.ragFileIds().getOrNull() + }, + "ragResources", + ) + assertEquals(3, ragStore.similarityTopK().getOrNull(), "similarityTopK") + assertEquals(0.7, ragStore.vectorDistanceThreshold().getOrNull(), "vectorDistanceThreshold") + + // LlmResponseCodec / UsageMetadataCodec: the model attaches both to every turn. + val usage = assertNotNull(events.firstNotNullOfOrNull { it.usageMetadata }, "usageMetadata") + assertEquals(8, usage.totalTokenCount, "totalTokenCount") + assertEquals(3, usage.promptTokenCount, "promptTokenCount") + assertEquals("ON_DEMAND", usage.trafficType, "trafficType") + assertEquals( + listOf(1), + usage.toolUsePromptTokensDetails?.map { it.tokenCount }, + "toolUsePromptTokensDetails", + ) + assertTrue( + events.any { it.finishReason != null }, + "finishReason should cross via LlmResponseCodec", + ) + + // PartCodec: the rich parts the model produced must be readable on the Kotlin side, and the + // partial args must convert back for the next request. + val partialArg = + assertNotNull( + events.firstNotNullOfOrNull { e -> + e.content?.parts?.firstNotNullOfOrNull { it.functionCall?.partialArgs?.firstOrNull() } + }, + "partialArgs should cross to the Kotlin side", + ) + assertEquals("hi", (partialArg.value as? KtPartialArgValue.StringValue)?.value, "partialArg") + assertEquals("$.text", partialArg.jsonPath, "partialArg jsonPath") + val video = + assertNotNull( + events.firstNotNullOfOrNull { e -> + e.content?.parts?.firstNotNullOfOrNull { it.videoMetadata } + }, + "videoMetadata should cross to the Kotlin side", + ) + assertEquals(1.seconds, video.startOffset, "videoMetadata startOffset") + assertEquals(2.seconds, video.endOffset, "videoMetadata endOffset") + assertEquals(24.0, video.fps, "videoMetadata fps") + val expectedCustomMetadata: Map = mapOf("tag" to "v1", "score" to 0.5f) + assertEquals( + expectedCustomMetadata, + modelResponses.firstNotNullOfOrNull { it.customMetadata?.takeIf { m -> m.isNotEmpty() } }, + "customMetadata should cross to the Kotlin side", + ) + // toJava: the partial args survive back into the history the Java model is handed. + assertEquals( + "hi", + model.requests + .last() + .contents() + .firstNotNullOfOrNull { c -> + c.parts().getOrNull().orEmpty().firstNotNullOfOrNull { part -> + part.functionCall().getOrNull()?.partialArgs()?.getOrNull()?.firstOrNull() + } + } + ?.stringValue() + ?.getOrNull(), + "partialArgs should convert back for the Java request", + ) + } + + @Test + fun ktRunner_artifactServiceApis_throughTheAdapter() = runBlocking { + val javaArtifacts = JavaInMemoryArtifactService() + val runner = + KtInMemoryRunner( + agent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(SequentialJavaModel(listOf(modelText("done")))), + ), + appName = "app", + artifactService = JavaAdkToKt.asKtArtifactService(javaArtifacts), + ) + val key = KtSessionKey("app", "u", "s") + val artifacts = runner.artifactService!! + + // PartCodec inlineData and fileData both round-trip through the adapted service. + assertEquals( + 0, + artifacts.saveArtifact( + key, + "img.png", + KtPart(inlineData = KtBlob(mimeType = "image/png", data = byteArrayOf(1, 2, 3))), + ), + "the first save of a filename is version 0", + ) + assertEquals( + 0, + artifacts.saveArtifact( + key, + "doc.txt", + KtPart(fileData = KtFileData(fileUri = "gs://b/doc.txt", mimeType = "text/plain")), + ), + "the first save of a filename is version 0", + ) + val loadedInline = assertNotNull(artifacts.loadArtifact(key, "img.png"), "inlineData artifact") + assertEquals("image/png", loadedInline.inlineData?.mimeType, "inlineData mimeType") + assertContentEquals(byteArrayOf(1, 2, 3), loadedInline.inlineData?.data, "inlineData bytes") + val loadedFile = assertNotNull(artifacts.loadArtifact(key, "doc.txt"), "fileData artifact") + assertEquals("gs://b/doc.txt", loadedFile.fileData?.fileUri, "fileData fileUri") + assertEquals("text/plain", loadedFile.fileData?.mimeType, "fileData mimeType") + + assertTrue(artifacts.listArtifactKeys(key).contains("img.png"), "listArtifactKeys") + assertTrue(artifacts.listVersions(key, "img.png").isNotEmpty(), "listVersions") + artifacts.deleteArtifact(key, "img.png") + assertTrue( + !artifacts.listArtifactKeys(key).contains("img.png"), + "a deleted artifact should be gone from the adapted service", + ) + } + + @Test + fun ktRunner_sessionServiceApis_throughTheAdapter() = runBlocking { + val javaSessions = JavaInMemorySessionService() + val runner = + KtInMemoryRunner( + agent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(SequentialJavaModel(listOf(modelText("done")))), + ), + appName = "app", + sessionService = JavaAdkToKt.asKtSessionService(javaSessions), + ) + val key = KtSessionKey("app", "u", "s") + + runner.turn() + + assertTrue(runner.sessionService.listSessions("app", "u").sessions.isNotEmpty(), "listSessions") + assertNotNull( + runner.sessionService.getSession(key, KtGetSessionConfig(numRecentEvents = 1)), + "getSession with a config", + ) + assertTrue( + runner.sessionService.listEvents(key).events.isNotEmpty(), + "listEvents should return the run's events through the adapted service", + ) + runner.sessionService.deleteSession(key) + assertTrue( + runner.sessionService.getSession(key) == null, + "a deleted session should be gone from the adapted service", + ) + } + + @Test + fun ktRunner_runsMultipleTurns_accumulatingHistory() = runBlocking { + // Records how many contents (history) the Java model sees on each turn. + val requestContentSizes = mutableListOf() + val model = + object : JavaBaseLlm("java-model") { + private var step = 0 + + override fun generateContent( + llmRequest: JavaLlmRequest, + stream: Boolean, + ): Flowable { + requestContentSizes.add(llmRequest.contents().size) + return Flowable.just( + JavaLlmResponse.builder().content(modelText("reply ${++step}")).build() + ) + } + + override fun connect(llmRequest: JavaLlmRequest): JavaBaseLlmConnection = + throw UnsupportedOperationException() + } + val runner = + KtInMemoryRunner(KtLlmAgent(name = "a", model = JavaAdkToKt.asKtModel(model)), "app") + + val turn1 = runner.turn("first") + val turn2 = runner.turn("second") + val turn3 = runner.turn("third") + + assertTrue(turn1.any { e -> e.content?.parts?.any { it.text == "reply 1" } == true }) + assertTrue(turn2.any { e -> e.content?.parts?.any { it.text == "reply 2" } == true }) + assertTrue(turn3.any { e -> e.content?.parts?.any { it.text == "reply 3" } == true }) + + // Each turn's request carries the history accumulated from prior turns (read back through the + // session service and converted by ContentCodec / EventCodec). + assertEquals(3, requestContentSizes.size) + assertTrue(requestContentSizes[1] > requestContentSizes[0], "turn 2 should see more history") + assertTrue(requestContentSizes[2] > requestContentSizes[1], "turn 3 should see more history") + + // All three turns' events are persisted on the one session. + val session = runner.sessionService.getSession(KtSessionKey("app", "u", "s"))!! + assertTrue(session.events.size >= 6, "expected user+model events across 3 turns") + } + + @Test + fun ktRunner_javaToolConfirmation_requestAndResume() = runBlocking { + val model = + SequentialJavaModel( + listOf(modelFunctionCall("java_confirm", emptyMap()), modelText("confirmed done")) + ) + val agent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(model), + tools = listOf(JavaAdkToKt.asKtTool(JavaConfirmTool())), + ) + val runner = KtInMemoryRunner(agent, appName = "app") + + // Turn 1: the model calls the gated Java tool; it calls requestConfirmation(), so JavaToolToKt + // copies the request onto the Kotlin actions and the engine emits an adk_request_confirmation + // call and pauses. + val turn1 = runner.turn() + val confirmationId = + turn1 + .flatMap { it.content?.parts.orEmpty() } + .mapNotNull { it.functionCall } + .firstOrNull { it.name == "adk_request_confirmation" } + ?.id + assertTrue(confirmationId != null, "turn 1 should emit an adk_request_confirmation call") + + // Turn 2: resume by sending the user's approval as a function response to that call. + val turn2 = + runner + .runAsync( + userId = "u", + sessionId = "s", + newMessage = + KtContent( + role = "user", + parts = + listOf( + KtPart( + functionResponse = + KtFunctionResponse( + name = "adk_request_confirmation", + id = confirmationId, + response = mapOf("confirmed" to true), + ) + ) + ), + ), + ) + .toList() + + // On resume, ktToolContextToJava carries the confirmation to the Java tool (via + // ToolConfirmationCodec.toJava), so it proceeds and reports "confirmed". + val confirmResponse = + turn2 + .flatMap { it.content?.parts.orEmpty() } + .mapNotNull { it.functionResponse } + .firstOrNull { it.name == "java_confirm" } + assertEquals("confirmed", confirmResponse?.response?.get("status")) + } + + @Test + fun ktRunner_javaToolMutatingLiveActions_propagatesControlFlow() = runBlocking { + // The tool transfers to this sub-agent; its reply proves transferToAgent propagated live. + val subAgent = + KtLlmAgent( + name = "b", + model = JavaAdkToKt.asKtModel(SequentialJavaModel(listOf(modelText("transferred reply")))), + ) + val agent = + KtLlmAgent( + name = "a", + model = + JavaAdkToKt.asKtModel( + SequentialJavaModel( + listOf(modelFunctionCall("java_control_flow", emptyMap()), modelText("a-final")) + ) + ), + tools = listOf(JavaAdkToKt.asKtTool(JavaControlFlowTool())), + subAgents = listOf(subAgent), + ) + val runner = KtInMemoryRunner(agent, appName = "app") + + val events = runner.turn() + + // The Java tool mutated toolContext.actions() in place; the live KtEventActionsToJavaView must + // carry every control-flow signal onto the Kotlin function-response event. + val actionEvent = events.firstOrNull { it.actions.transferToAgent == "b" } + assertTrue( + actionEvent != null, + "expected a function-response event carrying the tool's actions", + ) + assertTrue(actionEvent.actions.escalate, "escalate should propagate") + assertTrue(actionEvent.actions.skipSummarization, "skipSummarization should propagate") + assertTrue(actionEvent.actions.endOfAgent, "endOfAgent should propagate") + + // The transfer executed (findAgent("b") + ran it), confirming transferToAgent took effect. + assertTrue( + events.any { e -> e.content?.parts?.any { it.text == "transferred reply" } == true }, + "expected the transferred-to agent to run", + ) + } + + @Test + fun ktRunner_receivesGroundingFromJavaModel() = runBlocking { + val grounding = + GenaiGroundingMetadata.builder() + .webSearchQueries(listOf("adk kotlin")) + .retrievalQueries(listOf("adk kotlin retrieval")) + .groundingChunks( + listOf( + GenaiGroundingChunk.builder() + .web(GenaiGroundingChunkWeb.builder().uri("https://x.test").domain("x.test").build()) + .build(), + GenaiGroundingChunk.builder() + .retrievedContext( + GenaiGroundingChunkRetrievedContext.builder().uri("gs://c").text("ctx").build() + ) + .build(), + ) + ) + .groundingSupports( + listOf( + GenaiGroundingSupport.builder() + .segment(GenaiSegment.builder().startIndex(0).endIndex(5).text("hello").build()) + .groundingChunkIndices(listOf(0)) + .confidenceScores(listOf(0.9f)) + .build() + ) + ) + .searchEntryPoint(GenaiSearchEntryPoint.builder().renderedContent("

").build()) + .retrievalMetadata( + GenaiRetrievalMetadata.builder().googleSearchDynamicRetrievalScore(0.5f).build() + ) + .build() + val model = + object : JavaBaseLlm("java-model") { + override fun generateContent( + llmRequest: JavaLlmRequest, + stream: Boolean, + ): Flowable = + Flowable.just( + JavaLlmResponse.builder() + .content(modelText("grounded")) + .groundingMetadata(grounding) + .build() + ) + + override fun connect(llmRequest: JavaLlmRequest): JavaBaseLlmConnection = + throw UnsupportedOperationException() + } + // The engine does not re-surface a response's grounding on the emitted event, so capture it in + // a bridged Java plugin's afterModel instead: that hands the plugin the Kotlin response + // converted back to Java, exercising GroundingMetadataCodec in both directions. + val seen = AtomicReference() + val capturingPlugin = + object : JavaBasePlugin("grounding-capture") { + override fun afterModelCallback( + callbackContext: JavaCallbackContext, + llmResponse: JavaLlmResponse, + ): Maybe { + llmResponse.groundingMetadata().ifPresent { seen.set(it) } + return Maybe.empty() + } + } + val runner = + KtInMemoryRunner( + KtApp( + appName = "app", + rootAgent = KtLlmAgent(name = "a", model = JavaAdkToKt.asKtModel(model)), + plugins = listOf(JavaAdkToKt.asKtPlugin(capturingPlugin)), + ) + ) + + val events = runner.turn() + + assertTrue( + events.any { e -> e.content?.parts?.any { it.text == "grounded" } == true }, + "expected the grounded model reply", + ) + + // GroundingMetadataCodec: assert the payload survived, not merely that nothing threw. + val g = assertNotNull(seen.get(), "the bridged plugin should see the grounding metadata") + assertEquals(listOf("adk kotlin"), g.webSearchQueries().getOrNull(), "webSearchQueries") + assertEquals( + listOf("adk kotlin retrieval"), + g.retrievalQueries().getOrNull(), + "retrievalQueries", + ) + val chunks = assertNotNull(g.groundingChunks().getOrNull(), "groundingChunks") + assertEquals("https://x.test", chunks[0].web().getOrNull()?.uri()?.getOrNull(), "web chunk uri") + assertEquals( + "ctx", + chunks[1].retrievedContext().getOrNull()?.text()?.getOrNull(), + "retrieved-context chunk text", + ) + val support = assertNotNull(g.groundingSupports().getOrNull()?.firstOrNull(), "support") + assertEquals("hello", support.segment().getOrNull()?.text()?.getOrNull(), "segment text") + assertEquals(listOf(0), support.groundingChunkIndices().getOrNull(), "chunk indices") + assertEquals( + "
", + g.searchEntryPoint().getOrNull()?.renderedContent()?.getOrNull(), + "searchEntryPoint", + ) + } + + @Test + fun ktRunner_bridgedNoOpPlugin_preservesAgentTransfer() = runBlocking { + // Regression: bridging any Java plugin must not drop the request's Kotlin-only toolsDict in + // beforeModel, or the request-scoped transfer_to_agent tool disappears and transfer breaks. + val subAgent = + KtLlmAgent( + name = "b", + model = JavaAdkToKt.asKtModel(SequentialJavaModel(listOf(modelText("transferred reply")))), + ) + val agent = + KtLlmAgent( + name = "a", + model = + JavaAdkToKt.asKtModel( + SequentialJavaModel( + listOf( + modelFunctionCall("transfer_to_agent", mapOf("agent_name" to "b")), + modelText("a-final"), + ) + ) + ), + subAgents = listOf(subAgent), + ) + val runner = + KtInMemoryRunner( + app = + KtApp( + appName = "app", + rootAgent = agent, + plugins = listOf(JavaAdkToKt.asKtPlugin(CountingJavaPlugin())), + ) + ) + + val events = runner.turn() + + assertTrue( + events.any { e -> e.content?.parts?.any { it.text == "transferred reply" } == true }, + "transfer_to_agent should still work when a Java plugin is bridged", + ) + } + + @Test + fun ktRunner_javaToolStateRemoval_appliesDeletion() = runBlocking { + val agent = + KtLlmAgent( + name = "a", + model = + JavaAdkToKt.asKtModel( + SequentialJavaModel( + listOf(modelFunctionCall("java_state_mutator", emptyMap()), modelText("done")) + ) + ), + tools = listOf(JavaAdkToKt.asKtTool(JavaStateMutatingTool())), + ) + val runner = KtInMemoryRunner(agent, appName = "app") + + val events = runner.turn() + + // The tool's removal writes the Java sentinel into the live delta; it must be translated to the + // Kotlin one so the engine deletes the key rather than persisting a foreign sentinel. + val deltaEvent = events.firstOrNull { it.actions.stateDelta.containsKey("gone") } + assertTrue(deltaEvent != null, "expected an event carrying the tool's state delta") + assertTrue( + deltaEvent.actions.stateDelta["gone"] === KtState.REMOVED, + "the Java removal sentinel should be translated to the Kotlin State.REMOVED", + ) + + val session = runner.sessionService.getSession(KtSessionKey("app", "u", "s"))!! + assertEquals("yes", session.state["kept"], "a kept key should persist") + assertTrue(!session.state.containsKey("gone"), "a removed key should not be in state") + } + + @Test + fun ktRunner_forwardsCachedContentToJavaModel() = runBlocking { + // Regression: GenerateContentConfig.cachedContent must reach the Java model so context caching + // is not silently disabled. + var receivedCachedContent: String? = null + val model = + object : JavaBaseLlm("java-model") { + override fun generateContent( + llmRequest: JavaLlmRequest, + stream: Boolean, + ): Flowable { + receivedCachedContent = llmRequest.config().getOrNull()?.cachedContent()?.getOrNull() + return Flowable.just(JavaLlmResponse.builder().content(modelText("hi")).build()) + } + + override fun connect(llmRequest: JavaLlmRequest): JavaBaseLlmConnection = + throw UnsupportedOperationException() + } + val agent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(model), + generateContentConfig = KtConfig(cachedContent = "cachedContents/abc"), + ) + val runner = KtInMemoryRunner(agent, appName = "app") + + runner.turn() + + assertEquals("cachedContents/abc", receivedCachedContent) + } + + @Test + fun ktArtifactService_saveAndReloadArtifact_roundTrips() = runBlocking { + val service = JavaAdkToKt.asKtArtifactService(JavaInMemoryArtifactService()) + val key = KtSessionKey("app", "u", "s") + + val reloaded = service.saveAndReloadArtifact(key, "note.txt", KtPart(text = "v1")) + + assertEquals("v1", reloaded.text) + } + + @Test + fun ktRunner_preservesThoughtOnlyPartRoundTrip() = runBlocking { + // A model response part carrying ONLY a thoughtSignature (no text/functionCall) must survive + // both directions: Java model -> Kotlin event (fromJava) and Kotlin history -> Java request + // (toJava). Dropping it breaks Gemini thinking continuity. + val requests = mutableListOf() + val model = + object : JavaBaseLlm("java-model") { + private var step = 0 + + override fun generateContent( + llmRequest: JavaLlmRequest, + stream: Boolean, + ): Flowable { + requests += llmRequest + val content = + if (step++ == 0) + GenaiContent.builder() + .role("model") + .parts( + listOf( + GenaiPart.builder().thought(true).thoughtSignature("sig".toByteArray()).build(), + GenaiPart.builder() + .functionCall( + GenaiFunctionCall.builder().name("java_echo").args(emptyMap()).build() + ) + .build(), + ) + ) + .build() + else modelText("done") + return Flowable.just(JavaLlmResponse.builder().content(content).build()) + } + + override fun connect(llmRequest: JavaLlmRequest): JavaBaseLlmConnection = + throw UnsupportedOperationException() + } + val agent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(model), + tools = listOf(JavaAdkToKt.asKtTool(JavaEchoTool())), + ) + val runner = KtInMemoryRunner(agent, appName = "app") + + val events = runner.turn() + + // fromJava: the thought-only part survives onto the emitted model event. + assertTrue( + events.any { e -> e.content?.parts?.any { it.thoughtSignature != null } == true }, + "thought-only part should survive Java model -> Kotlin event", + ) + // toJava: the thought-only part survives in the history sent back on the next turn. + assertTrue( + requests.last().contents().any { c -> + c.parts().getOrNull().orEmpty().any { it.thoughtSignature().isPresent } + }, + "thought-only part should survive Kotlin history -> Java request", + ) + } + + @Test + fun ktRunner_javaToolReadsAgentName() = runBlocking { + // A Java tool reading ToolContext.agentName() must not NPE: the tool-path invocation-context + // view wires the agent. + val agent = + KtLlmAgent( + name = "a", + model = + JavaAdkToKt.asKtModel( + SequentialJavaModel( + listOf(modelFunctionCall("java_agent_name", emptyMap()), modelText("done")) + ) + ), + tools = listOf(JavaAdkToKt.asKtTool(JavaAgentNameTool())), + ) + val runner = KtInMemoryRunner(agent, appName = "app") + + val events = runner.turn() + + val response = + events + .flatMap { it.content?.parts.orEmpty() } + .mapNotNull { it.functionResponse } + .firstOrNull { it.name == "java_agent_name" } + assertEquals("a", response?.response?.get("agent")) + } + + @Test + fun ktRunner_pluginAfterToolStateRemoval_appliesDeletion() = runBlocking { + val agent = + KtLlmAgent( + name = "a", + model = + JavaAdkToKt.asKtModel( + SequentialJavaModel( + listOf(modelFunctionCall("java_echo", mapOf("text" to "hi")), modelText("done")) + ) + ), + tools = listOf(JavaAdkToKt.asKtTool(JavaEchoTool())), + ) + val runner = + KtInMemoryRunner( + app = + KtApp( + appName = "app", + rootAgent = agent, + plugins = listOf(JavaAdkToKt.asKtPlugin(ToolStateRemovingPlugin())), + ) + ) + + runner.turn() + + // The plugin's afterTool removal writes the Java sentinel into the live delta; it must be + // translated so the engine deletes the key rather than persisting a foreign sentinel. + val session = runner.sessionService.getSession(KtSessionKey("app", "u", "s"))!! + assertEquals("yes", session.state["kept3"], "a kept key should persist") + assertTrue( + !session.state.containsKey("gone3"), + "a key removed in a plugin afterTool callback should not be in state", + ) + } + + @Test + fun ktRunner_javaToolSeesLiveSession_heldAcrossSteps() = runBlocking { + // A Java tool HOLDS the Session from step 1 and re-reads it in step 2. Being a live view it + // must reflect events and state added since; ReadonlyContext memoizes over it, so a + // re-projected snapshot would go stale. + val held = AtomicReference(null) + val eventsAtHold = AtomicInteger(-1) + + val holdTool = + object : JavaBaseTool("hold_session", "captures the session") { + override fun declaration(): Optional = + Optional.of(GenaiFunctionDeclaration.builder().name("hold_session").build()) + + @JvmSuppressWildcards + override fun runAsync( + args: Map, + toolContext: JavaToolContext, + ): Single> { + val session = toolContext.invocationContext().session() + held.set(session) + eventsAtHold.set(session.events().size) + toolContext.state()["color"] = "teal" + return Single.just(mapOf("ok" to true)) + } + } + val readHeldTool = + object : JavaBaseTool("read_held", "reads the held session") { + override fun declaration(): Optional = + Optional.of(GenaiFunctionDeclaration.builder().name("read_held").build()) + + @JvmSuppressWildcards + override fun runAsync( + args: Map, + toolContext: JavaToolContext, + ): Single> { + val session = held.get() ?: return Single.just(mapOf("error" to "nothing held")) + return Single.just( + mapOf( + "grew" to (session.events().size > eventsAtHold.get()), + "color" to (session.state()["color"] ?: "MISSING"), + ) + ) + } + } + + val agent = + KtLlmAgent( + name = "a", + model = + JavaAdkToKt.asKtModel( + SequentialJavaModel( + listOf( + modelFunctionCall("hold_session", emptyMap()), + modelFunctionCall("read_held", emptyMap()), + modelText("done"), + ) + ) + ), + tools = JavaAdkToKt.asKtTools(listOf(holdTool, readHeldTool)), + ) + val runner = KtInMemoryRunner(agent, appName = "app") + + val events = runner.turn() + + val response = + events + .flatMap { it.content?.parts.orEmpty() } + .mapNotNull { it.functionResponse } + .firstOrNull { it.name == "read_held" } + ?.response + assertEquals(true, response?.get("grew"), "a held Session's events() must grow in place") + assertEquals( + "teal", + response?.get("color"), + "a held Session's state() must reflect a value set after it was captured", + ) + } + + @Test + fun ktRunner_javaToolDrivesNativeKotlinServices_throughItsInvocationContext() = runBlocking { + // The primary one-way scenario: keep the Kotlin services, adapt only the Java tool. Because + // nothing here is an adapted Java service, ServiceAdapters cannot unwrap, so the tool really + // goes through KtSessionServiceToJava / KtArtifactServiceToJava / KtMemoryServiceToJava. + val observed = ConcurrentHashMap() + val serviceTool = + object : JavaBaseTool("use_services", "drives the engine's services from Java") { + override fun declaration(): Optional = + Optional.of(GenaiFunctionDeclaration.builder().name("use_services").build()) + + @JvmSuppressWildcards + override fun runAsync( + args: Map, + toolContext: JavaToolContext, + ): Single> { + val ic = toolContext.invocationContext() + val artifacts = ic.artifactService() + val sessions = ic.sessionService() + + // KtArtifactServiceToJava: save -> load -> list -> versions. + val version = + artifacts + .saveArtifact("app", "u", "s", "note.txt", GenaiPart.fromText("v1")) + .blockingGet() + observed["version"] = version + observed["loaded"] = + artifacts.loadArtifact("app", "u", "s", "note.txt").blockingGet()?.text()?.orElse("") + ?: "" + observed["keys"] = artifacts.listArtifactKeys("app", "u", "s").blockingGet().filenames() + observed["versions"] = artifacts.listVersions("app", "u", "s", "note.txt").blockingGet() + + // KtSessionServiceToJava: getSession, then appendEvent, whose store-mirroring is the + // most intricate method in the module. + val javaSession = sessions.getSession("app", "u", "s", Optional.empty()).blockingGet()!! + val eventsBefore = javaSession.events().size + val appended = + sessions + .appendEvent( + javaSession, + JavaEvent.builder() + .id(JavaEvent.generateEventId()) + .invocationId(ic.invocationId()) + .author("use_services") + .content(GenaiContent.fromParts(GenaiPart.fromText("from the java tool"))) + .build(), + ) + .blockingGet() + observed["appended_author"] = appended.author() + // appendEvent mirrors the Kotlin store back into this very Java session object. + observed["events_grew"] = javaSession.events().size > eventsBefore + observed["sessions"] = sessions.listSessions("app", "u").blockingGet().sessions().size + + // KtMemoryServiceToJava: add then search. + ic.memoryService().addSessionToMemory(javaSession).blockingAwait() + observed["memories"] = + ic.memoryService().searchMemory("app", "u", "java").blockingGet().memories().size + return Single.just(mapOf("ok" to true)) + } + } + + val agent = + KtLlmAgent( + name = "a", + model = + JavaAdkToKt.asKtModel( + SequentialJavaModel( + listOf(modelFunctionCall("use_services", emptyMap()), modelText("done")) + ) + ), + tools = listOf(JavaAdkToKt.asKtTool(serviceTool)), + ) + // Native Kotlin services throughout: this is what makes the Kt -> Java adapters reachable. + val runner = KtInMemoryRunner(agent, appName = "app") + + runner.turn() + + assertEquals(0, observed["version"], "first save of a filename is version 0") + assertEquals("v1", observed["loaded"], "load must return what save stored") + assertEquals(listOf("note.txt"), observed["keys"], "listArtifactKeys") + assertEquals(listOf(0), observed["versions"], "listVersions") + assertEquals( + true, + observed["events_grew"], + "appendEvent must mirror the store into the session", + ) + assertEquals(1, observed["sessions"], "listSessions") + assertEquals(1, observed["memories"], "searchMemory should hit the appended text") + } + + @Test + fun ktRunner_javaToolReplacingItsActions_deltasStillReachTheKotlinSide() = runBlocking { + // A Java tool may build a fresh EventActions and setActions(...) it, rather than mutating the + // live Kotlin-backed maps. Then the two delta maps are different objects and the adapter has to + // copy them across; without that copy the writes are silently lost. + val agent = + KtLlmAgent( + name = "a", + model = + JavaAdkToKt.asKtModel( + SequentialJavaModel( + listOf(modelFunctionCall("replace_actions", emptyMap()), modelText("done")) + ) + ), + tools = listOf(JavaAdkToKt.asKtTool(JavaActionsReplacingTool())), + ) + val runner = KtInMemoryRunner(agent, appName = "app") + + val events = runner.turn() + + assertEquals( + "from_actions", + runner.sessionService.getSession(KtSessionKey("app", "u", "s"))?.state?.get("replaced_state"), + "a wholesale setActions() state delta must reach the Kotlin session", + ) + assertEquals( + 7, + events.firstNotNullOfOrNull { it.actions.artifactDelta["replaced_artifact.txt"] }, + "a wholesale setActions() artifact delta must reach the Kotlin actions", + ) + } + + @Test + fun ktRunner_javaPluginRemovingStateInAgentCallback_removalReachesTheKotlinSide() = runBlocking { + // The Java removal sentinel differs from the Kotlin one, so every callback that lets a plugin + // write state has to reconcile it. Without that, the key survives as a Java sentinel object + // instead of being deleted. + val runner = + KtInMemoryRunner( + KtApp( + appName = "app", + rootAgent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(SequentialJavaModel(listOf(modelText("done")))), + ), + plugins = listOf(JavaAdkToKt.asKtPlugin(AgentStateRemovingPlugin())), + ) + ) + + runner.turn() + + val state = runner.sessionService.getSession(KtSessionKey("app", "u", "s"))?.state + for (phase in listOf("before", "after")) { + assertEquals( + "yes", + state?.get("agent_kept_$phase"), + "the plugin's ${phase}Agent write must persist", + ) + assertTrue( + state?.containsKey("agent_gone_$phase") != true, + "a key removed in ${phase}Agent must be deleted, not left as a Java sentinel", + ) + } + } + + @Test + fun asKtSessionService_bridgesCloseSession_toTheJavaService() = runBlocking { + // closeSession has a default no-op on both SPIs, so an unbridged override fails silently: the + // Kotlin engine would close a session and the Java service would simply never hear about it. + val closed = CopyOnWriteArrayList() + val delegate = JavaInMemorySessionService() + val javaSessions = + object : JavaBaseSessionService { + override fun closeSession(session: JavaSession): Completable { + closed.add(session.id()) + return Completable.complete() + } + + // The deprecated ConcurrentMap overload is the abstract one; the Map overload is a default + // that delegates to it, so an implementor has to override this one. + @Suppress("OVERRIDE_DEPRECATION") + override fun createSession( + appName: String, + userId: String, + state: ConcurrentMap?, + sessionId: String?, + ): Single = delegate.createSession(appName, userId, state, sessionId) + + override fun getSession( + appName: String, + userId: String, + sessionId: String, + config: Optional, + ): Maybe = delegate.getSession(appName, userId, sessionId, config) + + override fun listSessions(appName: String, userId: String) = + delegate.listSessions(appName, userId) + + override fun deleteSession(appName: String, userId: String, sessionId: String) = + delegate.deleteSession(appName, userId, sessionId) + + override fun listEvents(appName: String, userId: String, sessionId: String) = + delegate.listEvents(appName, userId, sessionId) + } + val ktService = JavaAdkToKt.asKtSessionService(javaSessions) + val session = ktService.createSession(KtSessionKey("app", "u", "s")) + + ktService.closeSession(session) + + assertEquals(listOf("s"), closed, "closing through the bridge must reach the Java service") + } + + private companion object { + fun modelFunctionCall(name: String, args: Map): GenaiContent = + GenaiContent.builder() + .role("model") + .parts( + GenaiPart.builder() + .functionCall(GenaiFunctionCall.builder().name(name).args(args).build()) + .build() + ) + .build() + + fun modelText(text: String): GenaiContent = + GenaiContent.builder().role("model").parts(GenaiPart.builder().text(text).build()).build() + } +} 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..f7b4b4511 --- /dev/null +++ b/tokt/src/test/java/com/google/adk/tokt/LiveInteropTools.java @@ -0,0 +1,126 @@ +/* + * 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.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; + +/** + * 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() {} + + @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); + } + + @Schema(description = "Records the user's favorite color in session state.") + public static ImmutableMap rememberColor( + @Schema(name = "color", description = "The color to remember.") String color, + @Schema(name = "toolContext") ToolContext toolContext) { + toolContext.state().put("favorite_color", color); + return ImmutableMap.of("status", "stored", "color", color); + } + + @Schema( + description = + "Requests human approval to spend money. Returns a pending ticket; the real decision" + + " arrives later, out of band (a long-running / human-in-the-loop tool).") + public static ImmutableMap requestApproval( + @Schema(name = "amount", description = "Amount to approve, in USD.") int amount) { + return ImmutableMap.of("status", "PENDING", "ticketId", "TICKET-42", "amount", amount); + } + + @Schema(description = "Permanently deletes a file. Requires user confirmation before it runs.") + public static ImmutableMap deleteFile( + @Schema(name = "path", description = "Absolute path of the file to delete.") String path) { + return ImmutableMap.of("status", "DELETED", "path", path); + } + + /** + * 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(); + } + } +}