Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,10 @@
<jspecify.version>1.0.0</jspecify.version>
<rxjava.version>3.1.12</rxjava.version>
<!-- ADK Kotlin engine interop (google-adk-tokt module). -->
<kotlin.version>2.1.20</kotlin.version>
<kotlin.version>2.3.21</kotlin.version>
<dokka.version>2.2.0</dokka.version>
<kotlinx-coroutines.version>1.11.0</kotlinx-coroutines.version>
<adk-kotlin.version>1.3.0</adk-kotlin.version>
<adk-kotlin.version>1.4.0</adk-kotlin.version>
<reactor-core.version>3.7.0</reactor-core.version>
<wiremock.version>2.35.1</wiremock.version>
<assertj.version>3.27.7</assertj.version>
Expand Down
10 changes: 5 additions & 5 deletions tokt/src/main/kotlin/com/google/adk/tokt/JavaAdkToKt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ import kotlinx.coroutines.CoroutineDispatcher
* `EventActions`, the adapter throws once the Java call returns - the engine's event actions have
* no equivalent.
*
* Writes to the context's `skipSummarization`, `requestedToolConfirmations`, `agentState`, and
* `rewindBeforeInvocationId` do cross to the engine.
* Writes to the context's `skipSummarization`, `requestedToolConfirmations`, `agentState`,
* `rewindBeforeInvocationId`, and `route` do cross to the engine.
*/
object JavaAdkToKt {

Expand Down Expand Up @@ -139,9 +139,9 @@ object JavaAdkToKt {
/**
* Adapts an ADK Java session service for the Kotlin engine, running its calls on `dispatcher`. A
* Java view of a Kotlin service is unwrapped to that Kotlin service, with no `dispatcher` hop.
* Resumption works across the adapter (`EventActions.agentState` crosses);
* `rewindBeforeInvocationId` crosses too, but a rewind drops the rewound turns only if the Java
* service stores that field.
* Resumption and rewinds work across the adapter if the Java service, like
* `InMemorySessionService`, returns the fields they read unchanged: `EventActions.agentState`,
* `rewindBeforeInvocationId`, and a workflow node's output, node info, and routes.
*/
@JvmStatic
@JvmOverloads
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,16 @@ import com.google.adk.events.EventActions as JavaEventActions
import com.google.adk.kt.events.EventActions as KtEventActions
import com.google.adk.tokt.codecs.EventCompactionCodec
import com.google.adk.tokt.codecs.ToolConfirmationCodec
import com.google.adk.tokt.codecs.WorkflowCodec
import com.google.adk.tokt.codecs.agentStateFromJava
import com.google.adk.tokt.codecs.reconcileRemovedSentinels

/**
* Copies what a bridged tool or plugin callback wrote on its [JavaEventActions] onto the live
* [KtEventActions] the engine reads. Fields the live view already writes through are re-applied
* idempotently; skip-summarization, tool confirmations, agentState, compaction and
* rewindBeforeInvocationId are carried here. A signal the engine cannot represent
* (requestedAuthConfigs, deletedArtifactIds) throws.
* [KtEventActions] the engine reads. Fields the live view writes through, such as the workflow
* route, are re-applied idempotently, which also carries them after a wholesale `setActions(...)`;
* skip-summarization, tool confirmations, and agentState are carried only here. A signal the engine
* cannot represent (requestedAuthConfigs, deletedArtifactIds) throws.
*/
internal fun reconcileActionsToKt(java: JavaEventActions, kt: KtEventActions) {
require(java.requestedAuthConfigs().isNullOrEmpty()) {
Expand All @@ -43,6 +44,7 @@ internal fun reconcileActionsToKt(java: JavaEventActions, kt: KtEventActions) {
java.agentState().ifPresent { kt.agentState = agentStateFromJava(it) }
java.compaction().ifPresent { kt.compaction = EventCompactionCodec.fromJava(it) }
java.rewindBeforeInvocationId().ifPresent { kt.rewindBeforeInvocationId = it }
java.route().ifPresent { routes -> kt.route = routes.map { WorkflowCodec.routeFromJava(it) } }
kt.endOfAgent = kt.endOfAgent || java.endOfAgent()
// Merge deltas only when the tool replaced the actions (else they are the same live Kotlin map).
if (java.stateDelta() !== kt.stateDelta) kt.stateDelta.putAll(java.stateDelta())
Expand Down
14 changes: 10 additions & 4 deletions tokt/src/main/kotlin/com/google/adk/tokt/codecs/EventCodec.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ import kotlin.jvm.optionals.getOrNull
* 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, the context-compaction summary,
* the resumable-run `agentState`, and the rewind marker `rewindBeforeInvocationId`), the
* streaming/error signals (`partial`, `turnComplete`, `interrupted`, `errorMessage`, `errorCode`,
* `finishReason`), the token `usageMetadata` ([UsageMetadataCodec]) and `groundingMetadata`
* ([GroundingMetadataCodec]), long-running tool ids, and the timestamp.
* the resumable-run `agentState`, the rewind marker `rewindBeforeInvocationId`, and the workflow
* `route`), the streaming/error signals (`partial`, `turnComplete`, `interrupted`, `errorMessage`,
* `errorCode`, `finishReason`), the token `usageMetadata` ([UsageMetadataCodec]) and
* `groundingMetadata` ([GroundingMetadataCodec]), a workflow node's `output` and `nodeInfo`
* ([WorkflowCodec]), long-running tool ids, and the timestamp.
*/
internal object EventCodec {

Expand Down Expand Up @@ -62,6 +63,8 @@ internal object EventCodec {
event.groundingMetadata?.let { builder.groundingMetadata(GroundingMetadataCodec.toJava(it)) }
event.modelVersion?.let { builder.modelVersion(it) }
event.customMetadata?.let { builder.customMetadata(CustomMetadataCodec.toJava(it)) }
event.output?.let { builder.output(WorkflowCodec.outputToJava(it)) }
event.nodeInfo?.let { builder.nodeInfo(WorkflowCodec.nodeInfoToJava(it)) }
builder.timestamp(event.timestamp)
return builder.build()
}
Expand Down Expand Up @@ -93,6 +96,7 @@ internal object EventCodec {
javaActions.compaction().getOrNull()?.let { EventCompactionCodec.fromJava(it) },
agentState = agentStateFromJava(javaActions.agentState().getOrNull()),
rewindBeforeInvocationId = javaActions.rewindBeforeInvocationId().getOrNull(),
route = javaActions.route().getOrNull()?.map { WorkflowCodec.routeFromJava(it) },
),
branch = event.branch().getOrNull(),
partial = event.partial().getOrNull() ?: false,
Expand All @@ -109,6 +113,8 @@ internal object EventCodec {
event.groundingMetadata().getOrNull()?.let { GroundingMetadataCodec.fromJava(it) },
modelVersion = event.modelVersion().getOrNull(),
customMetadata = event.customMetadata().getOrNull()?.let { CustomMetadataCodec.fromJava(it) },
output = event.output().getOrNull()?.let { WorkflowCodec.outputFromJava(it) },
nodeInfo = event.nodeInfo().getOrNull()?.let { WorkflowCodec.nodeInfoFromJava(it) },
timestamp = event.timestamp(),
)
}
Expand Down
20 changes: 17 additions & 3 deletions tokt/src/main/kotlin/com/google/adk/tokt/codecs/KtToJavaCodecs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import com.google.adk.kt.sessions.SessionKey as KtSessionKey
import com.google.adk.kt.sessions.State as KtState
import com.google.adk.sessions.Session as JavaSession
import com.google.adk.sessions.State as JavaState
import com.google.adk.workflow.Route as JavaRoute
import com.google.common.collect.ImmutableList
import java.util.AbstractList
import java.util.AbstractMap
import java.util.Optional
Expand Down Expand Up @@ -81,6 +83,17 @@ internal class KtEventActionsToJavaView(private val actions: KtEventActions) : J
actions.rewindBeforeInvocationId = rewindBeforeInvocationId
}

override fun route(): Optional<ImmutableList<JavaRoute>> =
Optional.ofNullable(
actions.route?.let { routes ->
ImmutableList.copyOf(routes.map { WorkflowCodec.routeToJava(it) })
}
)

override fun setRoute(route: List<JavaRoute>?) {
actions.route = route?.map { WorkflowCodec.routeFromJava(it) }
}

// Overridden so the write reaches the Kotlin side; the base setters write private fields the
// overridden getters never read.

Expand Down Expand Up @@ -113,9 +126,9 @@ internal class KtEventActionsToJavaView(private val actions: KtEventActions) : J
* 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), the
* requested tool confirmations, the compaction summary, the resumable-run `agentState`
* ([agentStateToJava]), and the rewind marker (`rewindBeforeInvocationId`). Used when exposing an
* existing Kotlin event to a Java runner ([EventCodec.toJava]); unlike [KtEventActionsToJavaView]
* (a live write sink), nothing writes back through it.
* ([agentStateToJava]), the rewind marker (`rewindBeforeInvocationId`), and the workflow `route`.
* Used when exposing an existing Kotlin event to a Java runner ([EventCodec.toJava]); unlike
* [KtEventActionsToJavaView] (a live write sink), nothing writes back through it.
*/
internal fun eventActionsToJava(actions: KtEventActions): JavaEventActions =
JavaEventActions.builder()
Expand All @@ -131,6 +144,7 @@ internal fun eventActionsToJava(actions: KtEventActions): JavaEventActions =
.compaction(actions.compaction?.let { EventCompactionCodec.toJava(it) })
.agentState(agentStateToJava(actions.agentState))
.rewindBeforeInvocationId(actions.rewindBeforeInvocationId)
.route(actions.route?.map { WorkflowCodec.routeToJava(it) })
.build()

/** Copies a Kotlin state delta to Java, mapping the Kotlin [KtState.REMOVED] deletion sentinel. */
Expand Down
125 changes: 125 additions & 0 deletions tokt/src/main/kotlin/com/google/adk/tokt/codecs/WorkflowCodec.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
* 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.JsonBaseModel
import com.google.adk.kt.annotations.FrameworkInternalApi
import com.google.adk.kt.serialization.anyToJsonElement
import com.google.adk.kt.serialization.jsonElementToAny
import com.google.adk.kt.types.Content as KtContent
import com.google.adk.kt.workflow.NodeInfo as KtNodeInfo
import com.google.adk.kt.workflow.Route as KtRoute
import com.google.adk.workflow.NodeInfo as JavaNodeInfo
import com.google.adk.workflow.Route as JavaRoute
import com.google.genai.types.Content as GenaiContent

/**
* Converts a workflow node's output, its [NodeInfo][KtNodeInfo], and the routes on `actions.route`
* between ADK Kotlin and the ADK Java facade.
*/
internal object WorkflowCodec {

/**
* Returns the Java form of a node's [output]: a Kotlin `Content` becomes a genai `Content`, and a
* kotlinx `JsonElement` becomes the plain maps, lists, and values of an event read from JSON.
* Maps and lists are copied with each entry converted; a value with no JSON form throws.
*/
@OptIn(FrameworkInternalApi::class)
fun outputToJava(output: Any?): Any? =
when (output) {
null,
is String,
is Boolean,
is Number -> output
is KtContent -> ContentCodec.toJava(output)
// A JsonObject or JsonArray is also a Map or List, so it converts entry by entry here.
is Map<*, *> ->
output.entries.associate { (key, value) -> key.toString() to outputToJava(value) }
is List<*> -> output.map { outputToJava(it) }
// Unwraps a JSON primitive or JSON null; throws if a value has no JSON form.
else -> jsonElementToAny(anyToJsonElement(output))
}

/**
* Returns the Kotlin form of a node's [output]: a genai `Content` becomes a Kotlin `Content`, and
* any other non-JSON-native object becomes its JSON form, as in an event read from JSON. Maps and
* lists are copied with each entry converted.
*/
fun outputFromJava(output: Any?): Any? =
when (output) {
null,
is String,
is Boolean,
is Int,
is Long,
is Float,
is Double,
is Short,
is Byte -> output
// ADK Kotlin has no JSON form for BigDecimal or BigInteger, so convert to Long or Double.
is Number -> output.toString().toLongOrNull() ?: output.toDouble()
is GenaiContent -> ContentCodec.fromJava(output)
is Map<*, *> ->
output.entries.associate { (key, value) -> key.toString() to outputFromJava(value) }
is List<*> -> output.map { outputFromJava(it) }
is Array<*> -> output.map { outputFromJava(it) }
// Write, then read: convertValue can return an embedded value, such as a byte[], unchanged.
else ->
outputFromJava(
JsonBaseModel.getMapper().let {
it.readValue(it.writeValueAsString(output), Any::class.java)
}
)
}

/** Returns the Java [JavaNodeInfo] copy of the Kotlin [info]. */
fun nodeInfoToJava(info: KtNodeInfo): JavaNodeInfo =
JavaNodeInfo.builder()
.path(info.path)
.outputFor(info.outputFor.orEmpty())
.messageAsOutput(info.messageAsOutput)
.build()

/**
* Returns the Kotlin [KtNodeInfo] copy of the Java [info]. An empty `outputFor` reads as null,
* the Kotlin default, since the Java list is never null.
*/
fun nodeInfoFromJava(info: JavaNodeInfo): KtNodeInfo =
KtNodeInfo(
path = info.path(),
outputFor = info.outputFor().ifEmpty { null },
messageAsOutput = info.messageAsOutput(),
)

/** Returns the Java [JavaRoute] copy of the Kotlin [route]. */
fun routeToJava(route: KtRoute): JavaRoute =
when (route) {
is KtRoute.Tag -> JavaRoute.Tag(route.value)
is KtRoute.Num -> JavaRoute.Num(route.value)
is KtRoute.Flag -> JavaRoute.Flag(route.value)
KtRoute.Default -> JavaRoute.Default.INSTANCE
}

/** Returns the Kotlin [KtRoute] copy of the Java [route]. */
fun routeFromJava(route: JavaRoute): KtRoute =
when (route) {
is JavaRoute.Tag -> KtRoute.Tag(route.value())
is JavaRoute.Num -> KtRoute.Num(route.value())
is JavaRoute.Flag -> KtRoute.Flag(route.value())
is JavaRoute.Default -> KtRoute.Default
}
}
Loading
Loading