diff --git a/pom.xml b/pom.xml index 52475d377..5534a4df8 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.10.2 + 0.6.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..6e933396f --- /dev/null +++ b/tokt/pom.xml @@ -0,0 +1,142 @@ + + + + 4.0.0 + + + com.google.adk + google-adk-parent + 1.7.1-SNAPSHOT + + + google-adk-tokt + Agent Development Kit - Kotlin engine interop + One-way interop that adapts ADK Java agents, tools, toolsets, plugins, services, and models so they can run on the ADK Kotlin engine. + + + + + 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/JavaAdkToKt.kt b/tokt/src/main/java/com/google/adk/tokt/JavaAdkToKt.kt new file mode 100644 index 000000000..dd164a257 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/JavaAdkToKt.kt @@ -0,0 +1,102 @@ +/* + * 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: adapt ADK Java tools, toolsets, plugins, services, and models so + * they can run on the ADK Kotlin engine. Whole-agent conversion is intentionally omitted (running a + * Java agent's own multi-step flow on the Kotlin runner is not yet supported); wrap the Java SPI + * pieces into a Kotlin `LlmAgent` instead. + * + * **Session is re-projected, not a live handle.** An adapted Java tool / toolset / plugin sees the + * session through `context.session()`, which is re-projected on each call, so a *fresh* read is + * always current. The returned `Session` is a snapshot though: unlike native ADK Java its + * `events()` list does not grow in place and its state is a copy. Re-read `context.session()` each + * time; do not cache a `Session` (or its `events()`) across tool calls or turns. State *writes* via + * `toolContext.state()` / `callbackContext.state()` still propagate to the Kotlin. + */ +object JavaAdkToKt { + + /** + * Adapts an ADK Java tool. Its `ToolContext.session()` is a per-call snapshot; see [JavaAdkToKt]. + */ + @JvmStatic fun asKtTool(javaTool: JavaBaseTool): KtBaseTool = JavaToolToKt(javaTool) + + /** Adapts a whole collection of ADK Java tools (e.g. an `LlmAgent`'s `tools`). */ + @JvmStatic + fun asKtTools(javaTools: List): List = javaTools.map { asKtTool(it) } + + /** + * Adapts an ADK Java toolset. Its `ReadonlyContext` session is a per-call snapshot; see + * [JavaAdkToKt]. + */ + @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. Its callback contexts expose a per-call session snapshot; see + * [JavaAdkToKt]. + */ + @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) + } + + @JvmStatic fun asKtModel(javaLlm: JavaBaseLlm): KtModel = JavaModelToKt(javaLlm) + + @JvmStatic + fun asKtSessionService(service: JavaSessionService): KtSessionService = + javaSessionServiceAsKt(service) + + @JvmStatic + fun asKtArtifactService(service: JavaArtifactService): KtArtifactService = + javaArtifactServiceAsKt(service) + + @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..fb3c3a175 --- /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.codecs.LlmRequestCodec +import com.google.adk.tokt.codecs.LlmResponseCodec +import kotlinx.coroutines.Dispatchers +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 the Kotlin's [Model] so the + * ADK Kotlin agent loop can call it. The model seam for routing ADK Java's `LlmAgent` onto the + * Kotlin. + * + * 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(Dispatchers.IO) +} 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..9bce79be6 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/adapters/JavaPluginToKt.kt @@ -0,0 +1,251 @@ +/* + * 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.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.ktCallbackContextToJava +import com.google.adk.tokt.context.ktInvocationContextToJava +import com.google.adk.tokt.context.ktToolContextToJava +import io.reactivex.rxjava3.core.Flowable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.reactive.awaitFirstOrNull +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 + * ([ktInvocationContextToJava] / [ktCallbackContextToJava] / [ktToolContextToJava]), invokes the + * Java plugin, and awaits its RxJava result as a coroutine. + * + * The Java agent each callback sees is the running engine agent wrapped via [ktAgentAsJava], so no + * root agent has to be supplied. 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: () -> Flowable): T? = + withContext(Dispatchers.IO) { source().awaitFirstOrNull() } + + /** + * Maps any Java removal sentinel a plugin wrote through the live callback state (which is backed + * by the Kotlin delta map) 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 = + ktInvocationContextToJava(invocationContext, ktAgentAsJava(invocationContext.agent)) + val replacement = onIo { + plugin.onUserMessageCallback(javaContext, ContentCodec.toJava(userMessage)).toFlowable() + } + return replacement?.let { ContentCodec.fromJava(it) } ?: userMessage + } + + override suspend fun beforeRun( + invocationContext: KtInvocationContext + ): CallbackChoice { + val javaContext = + ktInvocationContextToJava(invocationContext, ktAgentAsJava(invocationContext.agent)) + val halt = onIo { plugin.beforeRunCallback(javaContext).toFlowable() } + return if (halt != null) CallbackChoice.Break(ContentCodec.fromJava(halt)) + else CallbackChoice.Continue(Unit) + } + + override suspend fun onEvent(invocationContext: KtInvocationContext, event: KtEvent): KtEvent { + val javaContext = + ktInvocationContextToJava(invocationContext, ktAgentAsJava(invocationContext.agent)) + val replacement = onIo { + plugin.onEventCallback(javaContext, EventCodec.toJava(event)).toFlowable() + } + return replacement?.let { EventCodec.fromJava(it) } ?: event + } + + override suspend fun afterRun(invocationContext: KtInvocationContext) { + val javaContext = + ktInvocationContextToJava(invocationContext, ktAgentAsJava(invocationContext.agent)) + onIo { plugin.afterRunCallback(javaContext).toFlowable() } + } + + // Agent-level callbacks. + + override suspend fun beforeAgent( + context: KtCallbackContext + ): CallbackChoice { + val javaAgent = ktAgentAsJava(context.agent) + val override = onIo { + plugin + .beforeAgentCallback(javaAgent, ktCallbackContextToJava(context, javaAgent)) + .toFlowable() + } + reconcileState(context) + return if (override != null) CallbackChoice.Break(ContentCodec.fromJava(override)) + else CallbackChoice.Continue(KtEventActions()) + } + + override suspend fun afterAgent(context: KtCallbackContext): CallbackChoice { + val javaAgent = ktAgentAsJava(context.agent) + val override = onIo { + plugin.afterAgentCallback(javaAgent, ktCallbackContextToJava(context, javaAgent)).toFlowable() + } + 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, ktAgentAsJava(context.agent)), + builder, + ) + .toFlowable() + } + 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, ktAgentAsJava(context.agent)), + LlmResponseCodec.toJava(response), + ) + .toFlowable() + } + 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, ktAgentAsJava(context.agent)), + builder, + error, + ) + .toFlowable() + } + 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)).toFlowable() + } + 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).toFlowable() + } + 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).toFlowable() + } + reconcileToolState(context) + return if (fallback != null) CallbackChoice.Break(fallback) else CallbackChoice.Continue(Unit) + } + + override suspend fun close() { + onIo { plugin.close().toFlowable() } + } +} 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..ae829a812 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/adapters/JavaToolToKt.kt @@ -0,0 +1,105 @@ +/* + * 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.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.Dispatchers +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. + * + * When the Kotlin calls [run], the Kotlin [ToolContext] is converted to a Java `ToolContext` + * ([ktToolContextToJava]) - whose actions delegate live to the Kotlin, so a tool's action writes + * (state / artifact deltas and control-flow signals) are visible to the Kotlin - the Java tool's + * RxJava `Single` is awaited as a coroutine, and the result map is returned. The tool's schema is + * exposed to the Kotlin through [declaration] ([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()) { + + 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(Dispatchers.IO) { + 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. + 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(Dispatchers.IO) { + 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 removal writes the Java sentinel into the (live) delta; map it to the Kotlin one so + // the + // engine recognizes the deletion. + reconcileRemovedSentinels(kt.stateDelta) + // Tool confirmations the tool requested (toolContext.requestConfirmation(...)) live on the Java + // actions only; carry them to the Kotlin so its confirmation flow emits the request event. + for ((id, confirmation) in java.requestedToolConfirmations()) { + kt.requestedToolConfirmations[id] = ToolConfirmationCodec.toKotlin(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..e3f3e61ef --- /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.context.KtReadonlyContextToJavaView +import com.google.adk.tokt.context.ktToolContextToJava +import com.google.adk.tools.BaseToolset as JavaBaseToolset +import kotlinx.coroutines.Dispatchers +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(Dispatchers.IO) { + 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(Dispatchers.IO) { + 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..3ddf70d76 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/adapters/KtAgentToJava.kt @@ -0,0 +1,61 @@ +/* + * 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()` / + * `parentAgent()` / `rootAgent()` / `findAgent()` reflect the real tree). 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..05c1ca428 --- /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.toKotlin(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..b3df7ab80 --- /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's `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 toKotlin(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..a55a7fda6 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/EnumCodec.kt @@ -0,0 +1,32 @@ +/* + * 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 + +/** + * Maps a genai enum to the Kotlin enum of the same name. The genai and Kotlin enums share constant + * names, so this bridges them by name. [name] is evaluated inside the guard, so an absent value, a + * genai `knownEnum()` that throws on an unrecognized value, or a name with no Kotlin constant all + * resolve to null. + */ +internal inline fun > enumByNameOrNull(name: () -> String?): K? = + runCatching { name()?.let { enumValueOf(it) } }.getOrNull() + +/** 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..3ac4c17e0 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/EventCodec.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.codecs + +import com.google.adk.events.Event as JavaEvent +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]. */ + 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 { + val javaActions = event.actions() + 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() ?: emptyMap()).toMutableMap(), + transferToAgent = javaActions?.transferToAgent()?.getOrNull(), + escalate = javaActions?.escalate()?.orElse(false) ?: false, + skipSummarization = javaActions?.skipSummarization()?.orElse(false) ?: false, + endOfAgent = javaActions?.endOfAgent() ?: false, + requestedToolConfirmations = + (javaActions?.requestedToolConfirmations() ?: emptyMap()) + .mapValues { ToolConfirmationCodec.toKotlin(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.toKotlin(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..d4efc2090 --- /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's `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]. 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..f33df1d33 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/GenerateContentConfigCodec.kt @@ -0,0 +1,369 @@ +/* + * 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's `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)) } + config.serviceTier?.let { builder.serviceTier(GenaiServiceTier(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..3c837a6f0 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/GroundingMetadataCodec.kt @@ -0,0 +1,170 @@ +/* + * 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.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.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's `kt.types.GroundingMetadata`, in both directions: image/web + * search queries, grounding chunks (web / retrieved context), 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. 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(), + 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.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) }, + ) + + private fun chunkToJava(chunk: KtGroundingChunk): GenaiGroundingChunk { + val builder = GenaiGroundingChunk.builder() + chunk.web?.let { builder.web(webToJava(it)) } + chunk.retrievedContext?.let { builder.retrievedContext(retrievedContextToJava(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..ada671339 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/KtToJavaCodecs.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.tokt.codecs + +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.State as KtState +import com.google.adk.sessions.Session as JavaSession +import com.google.adk.sessions.State as JavaState +import java.util.Optional +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 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 by [JavaToolToKt] after the tool + * runs. A tool that instead *replaces* its actions (`setActions(...)`) is likewise reconciled by + * [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) }) +} + +/** + * 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, so every field is populated. + */ +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 + } + +/** + * Converts a Kotlin [KtSession] to a Java [JavaSession] snapshot. The state and events are copied, + * not shared: `JavaState` copies the Kotlin state map (it is not a `ConcurrentMap`) and events are + * converted via [EventCodec]. Because the view is re-projected on every `context.session()` call it + * still reflects the current Kotlin session on each read; state *writes* from Java propagate via + * the live `eventActions.stateDelta` (see [KtEventActionsToJavaView]), not through this copied map. + */ +internal fun ktSessionToJava(session: KtSession): JavaSession = + JavaSession.builder(session.key.id ?: "") + .appName(session.key.appName) + .userId(session.key.userId) + .state(JavaState(session.state)) + .events(session.events.map { EventCodec.toJava(it) }) + .lastUpdateTime(session.lastUpdateTime.toJavaInstant()) + .build() 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..67b5eecb4 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/LlmRequestCodec.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.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]). + * + * 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..7c21876c3 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/LlmResponseCodec.kt @@ -0,0 +1,72 @@ +/* + * 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's + * `kt.models.LlmResponse` (both directions): the Kotlin agent loop consumes a Java model's response + * ([JavaModelToKt]), and an Kt-backed Java model returns the Kotlin's response (the model adapter). + * + * 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.toKotlin(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..c4f92c521 --- /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 toKotlin(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 toKotlin(response: JavaSearchMemoryResponse): KtSearchMemoryResponse = + KtSearchMemoryResponse(memories = response.memories().map { toKotlin(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..a92e81f34 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/PartCodec.kt @@ -0,0 +1,212 @@ +/* + * 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.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 kotlin.jvm.optionals.getOrNull +import kotlin.time.toJavaDuration +import kotlin.time.toKotlinDuration + +/** + * Converts a [Part] 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 empty/unmapped parts. + */ +internal object PartCodec { + + /** Returns the Kotlin [KtPart] view of the genai [part], or null if it carries nothing mapped. */ + fun toKotlin(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 { videoMetadataToKotlin(it) } + val base = + when { + functionCall != null -> KtPart(functionCall = functionCallToKotlin(functionCall)) + functionResponse != null -> + KtPart(functionResponse = functionResponseToKotlin(functionResponse)) + inlineData != null -> KtPart(inlineData = blobToKotlin(inlineData)) + fileData != null -> KtPart(fileData = fileDataToKotlin(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 -> 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 videoMetadataToKotlin(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 blobToKotlin(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 fileDataToKotlin(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 functionCallToKotlin(call: GenaiFunctionCall): KtFunctionCall = + KtFunctionCall( + name = call.name().getOrNull() ?: "", + args = call.args().getOrNull().orEmpty(), + id = call.id().getOrNull(), + partialArgs = call.partialArgs().getOrNull()?.map { partialArgToKotlin(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 partialArgToKotlin(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 functionResponseToKotlin(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/SchemaCodec.kt b/tokt/src/main/java/com/google/adk/tokt/codecs/SchemaCodec.kt new file mode 100644 index 000000000..f61a959b2 --- /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..503112ef0 --- /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 toKotlin(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..c1ff34c79 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/StateDeltaCodec.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.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) { + // Snapshot the keys to update so we don't mutate the map while iterating it. + for (key in delta.filterValues { it === JavaState.REMOVED }.keys) { + delta[key] = 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..dfbd6386c --- /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's + * 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 toKotlin(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..60b43182a --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/codecs/UsageMetadataCodec.kt @@ -0,0 +1,81 @@ +/* + * 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 kotlin.jvm.optionals.getOrNull + +/** + * Converts token-usage metadata between the genai + * [GenerateContentResponseUsageMetadata][GenaiUsageMetadata] the ADK Java facade exposes and the + * Kotlin's `kt.types.UsageMetadata`. Carries the prompt/candidates/total/thoughts/tool-use/cached + * token counts and the per-modality token breakdowns. 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) }, + ) + + /** 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) }) + } + 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..6c5a1e5a3 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/context/KtCallbackContextToJava.kt @@ -0,0 +1,84 @@ +/* + * 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.Session as JavaSession +import com.google.adk.tokt.codecs.ContentCodec +import com.google.adk.tokt.codecs.KtEventActionsToJavaView +import com.google.adk.tokt.codecs.ktSessionToJava +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. [session] + * is re-projected on each read so it always reflects 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 session(): JavaSession = ktSessionToJava(context.session) + + override fun appName(): String = context.session.key.appName + + override fun userId(): String = context.session.key.userId + + @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. Session + * service is not exposed by a callback context and is not needed for callback operations. + */ +private fun callbackContextJavaBuilder(context: KtCallbackContext): JavaInvocationContext.Builder { + val builder = JavaInvocationContext.builder().invocationId(context.invocationId) + 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). [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/KtContextToJava.kt b/tokt/src/main/java/com/google/adk/tokt/context/KtContextToJava.kt new file mode 100644 index 000000000..47f86d5cf --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/context/KtContextToJava.kt @@ -0,0 +1,79 @@ +/* + * 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.kt.agents.InvocationContext as KtInvocationContext +import com.google.adk.kt.annotations.FrameworkInternalApi +import com.google.adk.sessions.Session as JavaSession +import com.google.adk.tokt.codecs.ContentCodec +import com.google.adk.tokt.codecs.ktSessionToJava +import com.google.adk.tokt.services.ktArtifactServiceAsJava +import com.google.adk.tokt.services.ktMemoryServiceAsJava +import com.google.adk.tokt.services.ktSessionServiceAsJava +import java.util.Optional + +/** + * Builds a Java [JavaInvocationContext] from a Kotlin [context] for [javaAgent], exposing services + * as Java views (unwrapped to the original Java service when the Kotlin one is itself a wrapped + * Java service) and a live view of the Kotlin session. Used by [JavaPluginToKt] so a Java plugin's + * callbacks hit the Kotlin services and see the current session events rather than a stale + * snapshot. Services are wired only when present (parity with the tool/agent-level + * [KtInvocationContextToJavaView]). + */ +@OptIn(FrameworkInternalApi::class) +internal fun ktInvocationContextToJava( + context: KtInvocationContext, + javaAgent: JavaBaseAgent, +): JavaInvocationContext { + val builder = + JavaInvocationContext.builder() + .invocationId(context.invocationId) + .agent(javaAgent) + .session(ktSessionToJava(context.session)) + .callbackContextData(context.frameworkData.callbackContextData) + context.sessionService?.let { builder.sessionService(ktSessionServiceAsJava(it)) } + context.artifactService?.let { builder.artifactService(ktArtifactServiceAsJava(it)) } + context.memoryService?.let { builder.memoryService(ktMemoryServiceAsJava(it)) } + context.userContent?.let { builder.userContent(ContentCodec.toJava(it)) } + return LiveSessionInvocationContext(builder, context) +} + +/** + * Java context backed live by the Kotlin [ktContext]: [session] re-projects the current Kotlin + * session on each read, and branch / end-invocation read (and write, for end-invocation) through to + * the Kotlin context - parity with the tool/agent-level [KtInvocationContextToJavaView]. + */ +private class LiveSessionInvocationContext( + builder: JavaInvocationContext.Builder, + private val ktContext: KtInvocationContext, +) : JavaInvocationContext(builder) { + override fun session(): JavaSession = ktSessionToJava(ktContext.session) + + override fun branch(): Optional = Optional.ofNullable(ktContext.branch) + + override fun branch(branch: String?) { + // kt InvocationContext.branch is immutable (val); branch writes are not propagated. + } + + override fun endInvocation(): Boolean = ktContext.isEndOfInvocation + + override fun setEndInvocation(endInvocation: Boolean) { + ktContext.isEndOfInvocation = endInvocation + } +} 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..cda81b03c --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/context/KtToJava.kt @@ -0,0 +1,145 @@ +/* + * 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.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.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.sessions.Session as JavaSession +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.ToolConfirmationCodec +import com.google.adk.tokt.codecs.ktSessionToJava +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 + +/** + * 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 Kotlin + * pulls tools from a Java toolset ([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 = ctx.session.key.id ?: "" + + 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" + ) +} + +/** + * 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). + */ +private fun ktInvocationContextJavaBuilder(ic: KtInvocationContext): JavaInvocationContext.Builder { + val builder = + JavaInvocationContext.builder() + .invocationId(ic.invocationId) + // Wire the agent (as an inspection-only view) so a Java tool reading ToolContext.agentName() + // / invocationContext.agent() does not hit a null. + .agent(ktAgentAsJava(ic.agent)) + ic.sessionService?.let { builder.sessionService(ktSessionServiceAsJava(it)) } + ic.artifactService?.let { builder.artifactService(ktArtifactServiceAsJava(it)) } + ic.memoryService?.let { builder.memoryService(ktMemoryServiceAsJava(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. [session] is re-projected on each read so it always + * reflects the current Kotlin session (never a stale snapshot); the session / artifact / memory + * services are wired through too. + */ +internal class KtInvocationContextToJavaView(private val ic: KtInvocationContext) : + JavaInvocationContext(ktInvocationContextJavaBuilder(ic)) { + + override fun invocationId(): String = ic.invocationId + + override fun branch(): Optional = Optional.ofNullable(ic.branch) + + override fun branch(branch: String?) { + // kt InvocationContext.branch is immutable (val); branch writes are not propagated. + } + + override fun endInvocation(): Boolean = ic.isEndOfInvocation + + override fun setEndInvocation(endInvocation: Boolean) { + ic.isEndOfInvocation = endInvocation + } + + override fun session(): JavaSession = ktSessionToJava(ic.session) + + override fun appName(): String = ic.session.key.appName + + override fun userId(): String = ic.session.key.userId + + @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..7825635cc --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/services/JavaArtifactServiceToKt.kt @@ -0,0 +1,115 @@ +/* + * 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.codecs.PartCodec +import kotlinx.coroutines.Dispatchers +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. Parts are converted with [PartCodec]. All Java-service calls + * run on Dispatchers.IO 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(Dispatchers.IO) { + service + .saveArtifact( + sessionKey.appName, + sessionKey.userId, + id(sessionKey), + filename, + toJava(artifact), + ) + .await() + } + + override suspend fun saveAndReloadArtifact( + sessionKey: SessionKey, + filename: String, + artifact: KtPart, + ): KtPart = + withContext(Dispatchers.IO) { + toKotlin( + service + .saveAndReloadArtifact( + sessionKey.appName, + sessionKey.userId, + id(sessionKey), + filename, + toJava(artifact), + ) + .await() + ) + } + + override suspend fun loadArtifact( + sessionKey: SessionKey, + filename: String, + version: Int?, + ): KtPart? = + withContext(Dispatchers.IO) { + service + .loadArtifact(sessionKey.appName, sessionKey.userId, id(sessionKey), filename, version) + .awaitSingleOrNull() + ?.let { PartCodec.toKotlin(it) } + } + + override suspend fun listArtifactKeys(sessionKey: SessionKey): List = + withContext(Dispatchers.IO) { + service + .listArtifactKeys(sessionKey.appName, sessionKey.userId, id(sessionKey)) + .await() + .filenames() + } + + override suspend fun deleteArtifact(sessionKey: SessionKey, filename: String) { + withContext(Dispatchers.IO) { + service + .deleteArtifact(sessionKey.appName, sessionKey.userId, id(sessionKey), filename) + .await() + } + } + + override suspend fun listVersions(sessionKey: SessionKey, filename: String): List = + withContext(Dispatchers.IO) { + service.listVersions(sessionKey.appName, sessionKey.userId, id(sessionKey), filename).await() + } + + private fun id(key: SessionKey): String = + requireNotNull(key.id) { "SessionKey.id must not be null for artifact operations" } + + private fun toJava(part: KtPart) = + requireNotNull(PartCodec.toJava(part)) { "Artifact part is empty or unmapped" } + + private fun toKotlin(part: com.google.genai.types.Part): KtPart = + requireNotNull(PartCodec.toKotlin(part)) { "Artifact part is empty or unmapped" } +} 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..b6950dbd9 --- /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.codecs.MemoryEntryCodec +import com.google.adk.tokt.codecs.ktSessionToJava +import kotlinx.coroutines.Dispatchers +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. + */ +internal class JavaMemoryServiceToKt(internal val service: JavaBaseMemoryService) : + KtMemoryService { + + override suspend fun addSessionToMemory(session: KtSession) { + // On Dispatchers.IO: a user's Java memory service may block on subscribe. + withContext(Dispatchers.IO) { service.addSessionToMemory(ktSessionToJava(session)).await() } + } + + override suspend fun searchMemory( + appName: String, + userId: String, + query: String, + ): KtSearchMemoryResponse = + withContext(Dispatchers.IO) { + MemoryEntryCodec.toKotlin(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..6a4e46a21 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/services/JavaSessionServiceToKt.kt @@ -0,0 +1,132 @@ +/* + * 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.codecs.EventCodec +import com.google.adk.tokt.codecs.SessionCodec +import com.google.adk.tokt.codecs.ktSessionToJava +import java.util.Optional +import kotlin.time.toJavaInstant +import kotlin.time.toKotlinInstant +import kotlinx.coroutines.Dispatchers +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. 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 Dispatchers.IO: 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(Dispatchers.IO) { + SessionCodec.toKotlin(service.createSession(key.appName, key.userId, state, key.id).await()) + } + + override suspend fun getSession(key: SessionKey, config: KtGetSessionConfig?): KtSession? = + withContext(Dispatchers.IO) { + service + .getSession( + key.appName, + key.userId, + requireNotNull(key.id) { "SessionKey.id must not be null for getSession" }, + Optional.ofNullable(config?.toJava()), + ) + .awaitSingleOrNull() + ?.let { SessionCodec.toKotlin(it) } + } + + override suspend fun listSessions(appName: String, userId: String): KtListSessionsResponse = + withContext(Dispatchers.IO) { + KtListSessionsResponse( + sessions = + service.listSessions(appName, userId).await().sessions().map { SessionCodec.toKotlin(it) } + ) + } + + override suspend fun deleteSession(key: SessionKey) { + withContext(Dispatchers.IO) { + service + .deleteSession( + key.appName, + key.userId, + requireNotNull(key.id) { "SessionKey.id must not be null for deleteSession" }, + ) + .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(Dispatchers.IO) { + service + .listEvents( + key.appName, + key.userId, + requireNotNull(key.id) { "SessionKey.id must not be null for listEvents" }, + ) + ?.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(Dispatchers.IO) { + service.appendEvent(javaSession, EventCodec.toJava(event)).await() + } + // Keep the in-memory Kotlin session the runner holds in sync (state delta + event list). + val appended = super.appendEvent(session, event) + // The Java service is authoritative for lastUpdateTime (it may follow a different policy than + // the Kotlin's event-timestamp default), so mirror its value onto the in-memory session. + session.lastUpdateTime = javaSession.lastUpdateTime().toKotlinInstant() + 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..cf3b6fb12 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/services/KtArtifactServiceToJava.kt @@ -0,0 +1,130 @@ +/* + * 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.kt.types.Part as KtPart +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.Dispatchers +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 + * whose operations are the Kotlin's. 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(Dispatchers.IO) { + service.saveArtifact(SessionKey(appName, userId, sessionId), filename, toKotlin(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(Dispatchers.IO) { + toJava( + service.saveAndReloadArtifact( + SessionKey(appName, userId, sessionId), + filename, + toKotlin(artifact), + ) + ) + } + + override fun loadArtifact( + appName: String, + userId: String, + sessionId: String, + filename: String, + version: Int?, + ): Maybe = + rxMaybe(Dispatchers.IO) { + service.loadArtifact(SessionKey(appName, userId, sessionId), filename, version)?.let { + PartCodec.toJava(it) + } + } + + // Build a Guava ImmutableList directly to match the Java service's return type. + @Suppress("PreferKotlinApi") + override fun listArtifactKeys( + appName: String, + userId: String, + sessionId: String, + ): Single = + rxSingle(Dispatchers.IO) { + 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(Dispatchers.IO) { + service.deleteArtifact(SessionKey(appName, userId, sessionId), filename) + } + + // Build a Guava ImmutableList directly to match the Java service's return type. + @Suppress("PreferKotlinApi") + override fun listVersions( + appName: String, + userId: String, + sessionId: String, + filename: String, + ): Single> = + rxSingle(Dispatchers.IO) { + ImmutableList.copyOf(service.listVersions(SessionKey(appName, userId, sessionId), filename)) + } + + // Fail fast on an empty/unmapped part rather than persist an empty artifact (symmetric with + // JavaArtifactServiceToKt). + private fun toKotlin(part: GenaiPart): KtPart = + requireNotNull(PartCodec.toKotlin(part)) { "Artifact part is empty or unmapped" } + + private fun toJava(part: KtPart): GenaiPart = + requireNotNull(PartCodec.toJava(part)) { "Artifact part is empty or unmapped" } +} 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..bf283e56d --- /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.codecs.MemoryEntryCodec +import com.google.adk.tokt.codecs.SessionCodec +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.Dispatchers +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 + * whose operations are the Kotlin's. + */ +internal class KtMemoryServiceToJava(internal val service: KtMemoryService) : + JavaBaseMemoryService { + + override fun addSessionToMemory(session: JavaSession): Completable = + rxCompletable(Dispatchers.IO) { service.addSessionToMemory(SessionCodec.toKotlin(session)) } + + override fun searchMemory( + appName: String, + userId: String, + query: String, + ): Single = + rxSingle(Dispatchers.IO) { + 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..b7497e7d5 --- /dev/null +++ b/tokt/src/main/java/com/google/adk/tokt/services/KtSessionServiceToJava.kt @@ -0,0 +1,128 @@ +/* + * 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.codecs.EventCodec +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.Dispatchers +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. + */ +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(Dispatchers.IO) { + ktSessionToJava(service.createSession(SessionKey(appName, userId, sessionId), state)) + } + + override fun getSession( + appName: String, + userId: String, + sessionId: String, + config: Optional, + ): Maybe = + rxMaybe(Dispatchers.IO) { + service + .getSession(SessionKey(appName, userId, sessionId), config.getOrNull()?.toKotlin()) + ?.let { ktSessionToJava(it) } + } + + override fun listSessions(appName: String, userId: String): Single = + rxSingle(Dispatchers.IO) { + val response = service.listSessions(appName, userId) + JavaListSessionsResponse.builder() + .sessions(response.sessions.map { ktSessionToJava(it) }) + .build() + } + + override fun deleteSession(appName: String, userId: String, sessionId: String): Completable = + rxCompletable(Dispatchers.IO) { service.deleteSession(SessionKey(appName, userId, sessionId)) } + + override fun listEvents( + appName: String, + userId: String, + sessionId: String, + ): Single = + rxSingle(Dispatchers.IO) { + 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, then mirrors the Kotlin's 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(Dispatchers.IO) { + val key = SessionKey(session.appName(), session.userId(), session.id()) + service.appendEvent(SessionCodec.toKotlin(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. + val storedEvents = stored.events.map { EventCodec.toJava(it) } + synchronized(session.events()) { + session.events().clear() + session.events().addAll(storedEvents) + } + synchronized(session.state()) { + session.state().clear() + 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/KtRunnerInteropTest.kt b/tokt/src/test/java/com/google/adk/tokt/KtRunnerInteropTest.kt new file mode 100644 index 000000000..c300e0d00 --- /dev/null +++ b/tokt/src/test/java/com/google/adk/tokt/KtRunnerInteropTest.kt @@ -0,0 +1,1252 @@ +/* + * 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.CallbackContext as JavaCallbackContext +import com.google.adk.agents.InvocationContext as JavaInvocationContext +import com.google.adk.agents.ReadonlyContext as JavaReadonlyContext +import com.google.adk.artifacts.InMemoryArtifactService as JavaInMemoryArtifactService +import com.google.adk.kt.agents.LlmAgent as KtLlmAgent +import com.google.adk.kt.apps.App as KtApp +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.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.InMemorySessionService as JavaInMemorySessionService +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.genai.types.Content as GenaiContent +import com.google.genai.types.CustomMetadata as GenaiCustomMetadata +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.VideoMetadata as GenaiVideoMetadata +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Maybe +import io.reactivex.rxjava3.core.Single +import java.util.Optional +import kotlin.jvm.optionals.getOrNull +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking + +/** + * 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, + ) + check(reads.isNotEmpty()) + } + 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 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 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 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 + + override fun generateContent( + llmRequest: JavaLlmRequest, + stream: Boolean, + ): Flowable { + 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) + .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_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 + .runAsync(userId = "u", sessionId = "s", newMessage = KtContent.fromText("user", "go")) + .toList() + + 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 } + ) + } + + @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 + .runAsync(userId = "u", sessionId = "s", newMessage = KtContent.fromText("user", "hi")) + .toList() + + // 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 + .runAsync(userId = "u", sessionId = "s", newMessage = KtContent.fromText("user", "hi")) + .toList() + + // 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}", + ) + } + + @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 + .runAsync(userId = "u", sessionId = "s", newMessage = KtContent.fromText("user", "go")) + .toList() + + // 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")) + assertTrue(version >= 0, "expected an artifact version") + 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_exercisesConfigSchemaUsagePartsAndServiceApis() = 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, + ) + ) + ), + ), + ) + val agent = + KtLlmAgent( + name = "a", + model = JavaAdkToKt.asKtModel(model), + tools = listOf(JavaAdkToKt.asKtTool(JavaEchoTool())), + generateContentConfig = ktConfig, + ) + val runner = + KtInMemoryRunner( + app = KtApp(appName = "app", rootAgent = agent), + sessionService = JavaAdkToKt.asKtSessionService(javaSessions), + artifactService = JavaAdkToKt.asKtArtifactService(javaArtifacts), + ) + + val events = + runner + .runAsync(userId = "u", sessionId = "s", newMessage = KtContent.fromText("user", "go")) + .toList() + assertTrue(events.isNotEmpty(), "expected the rich-config run to produce events") + + val key = KtSessionKey("app", "u", "s") + + // PartCodec inlineData + fileData round-trips through the adapted artifact service. + val inlineVersion = + runner.artifactService!!.saveArtifact( + key, + "img.png", + KtPart(inlineData = KtBlob(mimeType = "image/png", data = byteArrayOf(1, 2, 3))), + ) + assertTrue(inlineVersion >= 0) + val fileVersion = + runner.artifactService!!.saveArtifact( + key, + "doc.txt", + KtPart(fileData = KtFileData(fileUri = "gs://b/doc.txt", mimeType = "text/plain")), + ) + assertTrue(fileVersion >= 0) + val loadedInline = runner.artifactService!!.loadArtifact(key, "img.png") + assertEquals("image/png", loadedInline?.inlineData?.mimeType) + + // Artifact service list / versions / delete. + assertTrue(runner.artifactService!!.listArtifactKeys(key).contains("img.png")) + assertTrue(runner.artifactService!!.listVersions(key, "img.png").isNotEmpty()) + runner.artifactService!!.deleteArtifact(key, "img.png") + + // Session service list / getSession(config) / listEvents / delete. + assertTrue(runner.sessionService.listSessions("app", "u").sessions.isNotEmpty()) + val withConfig = runner.sessionService.getSession(key, KtGetSessionConfig(numRecentEvents = 1)) + assertTrue(withConfig != null, "expected getSession with a config") + assertTrue(runner.sessionService.listEvents(key).events.size >= 0) + runner.sessionService.deleteSession(key) + } + + @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") + + suspend fun turn(text: String) = + runner + .runAsync(userId = "u", sessionId = "s", newMessage = KtContent.fromText("user", text)) + .toList() + + val turn1 = turn("first") + val turn2 = turn("second") + val turn3 = 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 + .runAsync(userId = "u", sessionId = "s", newMessage = KtContent.fromText("user", "go")) + .toList() + 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 + .runAsync(userId = "u", sessionId = "s", newMessage = KtContent.fromText("user", "go")) + .toList() + + // 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")) + .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() + } + val runner = + KtInMemoryRunner(KtLlmAgent(name = "a", model = JavaAdkToKt.asKtModel(model)), "app") + + val events = + runner + .runAsync(userId = "u", sessionId = "s", newMessage = KtContent.fromText("user", "go")) + .toList() + + // A grounded Java model response is consumed end-to-end: LlmResponseCodec.fromJava + + // GroundingMetadataCodec.fromJava convert the full grounding payload (chunks, supports, + // segment, + // search entry point, retrieval metadata) while the Kotlin agent produces its reply. The Kotlin + // engine does not re-surface a response's grounding on the emitted event, so this asserts the + // reply is produced rather than grounding fields on the event. + assertTrue( + events.any { e -> e.content?.parts?.any { it.text == "grounded" } == true }, + "expected the grounded model reply", + ) + } + + @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 + .runAsync(userId = "u", sessionId = "s", newMessage = KtContent.fromText("user", "go")) + .toList() + + 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 + .runAsync(userId = "u", sessionId = "s", newMessage = KtContent.fromText("user", "go")) + .toList() + + // 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 + .runAsync(userId = "u", sessionId = "s", newMessage = KtContent.fromText("user", "go")) + .toList() + + 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 + .runAsync(userId = "u", sessionId = "s", newMessage = KtContent.fromText("user", "go")) + .toList() + + // 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 + .runAsync(userId = "u", sessionId = "s", newMessage = KtContent.fromText("user", "go")) + .toList() + + 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 + .runAsync(userId = "u", sessionId = "s", newMessage = KtContent.fromText("user", "go")) + .toList() + + // 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", + ) + } + + 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() + } +}