Skip to content

Commit 4ff229b

Browse files
damianmomotgooglecopybara-github
authored andcommitted
feat: add rewindBeforeInvocationId to EventActions and carry it through the Kotlin interop
PiperOrigin-RevId: 988179449
1 parent f7d7a7c commit 4ff229b

7 files changed

Lines changed: 185 additions & 26 deletions

File tree

‎core/src/main/java/com/google/adk/events/EventActions.java‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public class EventActions extends JsonBaseModel {
4646
private @Nullable Map<String, Object> agentState;
4747
private @Nullable EventCompaction compaction;
4848
private @Nullable Object setModelResponse;
49+
private @Nullable String rewindBeforeInvocationId;
4950

5051
/** Default constructor for Jackson. */
5152
public EventActions() {
@@ -70,6 +71,7 @@ private EventActions(Builder builder) {
7071
this.agentState = builder.agentState;
7172
this.compaction = builder.compaction;
7273
this.setModelResponse = builder.setModelResponse;
74+
this.rewindBeforeInvocationId = builder.rewindBeforeInvocationId;
7375
}
7476

7577
@JsonProperty("skipSummarization")
@@ -230,6 +232,20 @@ public void setSetModelResponse(@Nullable Object setModelResponse) {
230232
this.setModelResponse = setModelResponse;
231233
}
232234

235+
/**
236+
* On a rewind event, the ID of the invocation to rewind before: the history from that invocation
237+
* up to the rewind event is dropped. ADK Java does not apply rewinds itself; the field exists for
238+
* runtimes that do, such as ADK Kotlin.
239+
*/
240+
@JsonProperty("rewindBeforeInvocationId")
241+
public Optional<String> rewindBeforeInvocationId() {
242+
return Optional.ofNullable(rewindBeforeInvocationId);
243+
}
244+
245+
public void setRewindBeforeInvocationId(@Nullable String rewindBeforeInvocationId) {
246+
this.rewindBeforeInvocationId = rewindBeforeInvocationId;
247+
}
248+
233249
public static Builder builder() {
234250
return new Builder();
235251
}
@@ -257,7 +273,8 @@ public boolean equals(Object o) {
257273
&& (endOfAgent == that.endOfAgent)
258274
&& Objects.equals(agentState, that.agentState)
259275
&& Objects.equals(compaction, that.compaction)
260-
&& Objects.equals(setModelResponse, that.setModelResponse);
276+
&& Objects.equals(setModelResponse, that.setModelResponse)
277+
&& Objects.equals(rewindBeforeInvocationId, that.rewindBeforeInvocationId);
261278
}
262279

263280
@Override
@@ -274,7 +291,8 @@ public int hashCode() {
274291
endOfAgent,
275292
agentState,
276293
compaction,
277-
setModelResponse);
294+
setModelResponse,
295+
rewindBeforeInvocationId);
278296
}
279297

280298
/** Builder for {@link EventActions}. */
@@ -291,6 +309,7 @@ public static class Builder {
291309
private @Nullable Map<String, Object> agentState;
292310
private @Nullable EventCompaction compaction;
293311
private @Nullable Object setModelResponse;
312+
private @Nullable String rewindBeforeInvocationId;
294313

295314
public Builder() {
296315
this.stateDelta = new ConcurrentHashMap<>();
@@ -314,6 +333,7 @@ private Builder(EventActions eventActions) {
314333
this.agentState = eventActions.agentState;
315334
this.compaction = eventActions.compaction;
316335
this.setModelResponse = eventActions.setModelResponse;
336+
this.rewindBeforeInvocationId = eventActions.rewindBeforeInvocationId;
317337
}
318338

319339
@CanIgnoreReturnValue
@@ -434,6 +454,13 @@ public Builder setModelResponse(@Nullable Object value) {
434454
return this;
435455
}
436456

457+
@CanIgnoreReturnValue
458+
@JsonProperty("rewindBeforeInvocationId")
459+
public Builder rewindBeforeInvocationId(@Nullable String value) {
460+
this.rewindBeforeInvocationId = value;
461+
return this;
462+
}
463+
437464
@CanIgnoreReturnValue
438465
public Builder merge(EventActions other) {
439466
other.skipSummarization().ifPresent(this::skipSummarization);
@@ -448,6 +475,7 @@ public Builder merge(EventActions other) {
448475
other.agentState().ifPresent(this::agentState);
449476
other.compaction().ifPresent(this::compaction);
450477
other.setModelResponse().ifPresent(this::setModelResponse);
478+
other.rewindBeforeInvocationId().ifPresent(this::rewindBeforeInvocationId);
451479
return this;
452480
}
453481

‎core/src/test/java/com/google/adk/events/EventActionsTest.java‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ public void merge_mergesAllFields() {
9090
new ConcurrentHashMap<>(ImmutableMap.of("tool2", TOOL_CONFIRMATION)))
9191
.endOfAgent(true)
9292
.setModelResponse(ImmutableMap.of("field1", "value1"))
93+
.rewindBeforeInvocationId("inv1")
9394
.build();
9495

9596
EventActions merged = eventActions1.toBuilder().merge(eventActions2).build();
@@ -111,6 +112,58 @@ public void merge_mergesAllFields() {
111112
assertThat(merged.endOfAgent()).isTrue();
112113
assertThat(merged.compaction()).hasValue(COMPACTION);
113114
assertThat(merged.setModelResponse()).hasValue(ImmutableMap.of("field1", "value1"));
115+
assertThat(merged.rewindBeforeInvocationId()).hasValue("inv1");
116+
}
117+
118+
@Test
119+
public void rewindBeforeInvocationId_roundTripsThroughToBuilder() {
120+
EventActions actions = EventActions.builder().rewindBeforeInvocationId("inv1").build();
121+
122+
EventActions rebuilt = actions.toBuilder().build();
123+
124+
assertThat(rebuilt).isEqualTo(actions);
125+
assertThat(rebuilt).isNotEqualTo(EventActions.builder().build());
126+
assertThat(rebuilt.rewindBeforeInvocationId()).hasValue("inv1");
127+
}
128+
129+
@Test
130+
public void rewindBeforeInvocationId_roundTripsThroughJson() {
131+
EventActions actions = EventActions.builder().rewindBeforeInvocationId("inv1").build();
132+
133+
String json = actions.toJson();
134+
EventActions deserialized = EventActions.fromJsonString(json, EventActions.class);
135+
136+
assertThat(json).contains("\"rewindBeforeInvocationId\":\"inv1\"");
137+
assertThat(deserialized.rewindBeforeInvocationId()).hasValue("inv1");
138+
}
139+
140+
@Test
141+
public void rewindBeforeInvocationId_absentByDefault_andOmittedFromJson() {
142+
EventActions actions = EventActions.builder().build();
143+
144+
assertThat(actions.rewindBeforeInvocationId()).isEmpty();
145+
assertThat(actions.toJson()).doesNotContain("rewindBeforeInvocationId");
146+
}
147+
148+
@Test
149+
public void merge_rewindBeforeInvocationId_absentInOther_keepsThis() {
150+
EventActions first = EventActions.builder().rewindBeforeInvocationId("inv1").build();
151+
EventActions second = EventActions.builder().build();
152+
153+
EventActions merged = first.toBuilder().merge(second).build();
154+
155+
assertThat(merged.rewindBeforeInvocationId()).hasValue("inv1");
156+
}
157+
158+
@Test
159+
public void setRewindBeforeInvocationId_setsAndClears() {
160+
EventActions actions = new EventActions();
161+
162+
actions.setRewindBeforeInvocationId("inv1");
163+
assertThat(actions.rewindBeforeInvocationId()).hasValue("inv1");
164+
165+
actions.setRewindBeforeInvocationId(null);
166+
assertThat(actions.rewindBeforeInvocationId()).isEmpty();
114167
}
115168

116169
@Test

‎tokt/src/main/kotlin/com/google/adk/tokt/JavaAdkToKt.kt‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ import kotlinx.coroutines.CoroutineDispatcher
6565
* `EventActions`, the adapter throws once the Java call returns - the engine's event actions have
6666
* no equivalent.
6767
*
68-
* Writes to the context's `skipSummarization`, `requestedToolConfirmations`, and `agentState` do
69-
* cross to the engine.
68+
* Writes to the context's `skipSummarization`, `requestedToolConfirmations`, `agentState`, and
69+
* `rewindBeforeInvocationId` do cross to the engine.
7070
*/
7171
object JavaAdkToKt {
7272

@@ -139,9 +139,9 @@ object JavaAdkToKt {
139139
/**
140140
* Adapts an ADK Java session service for the Kotlin engine, running its calls on `dispatcher`. A
141141
* Java view of a Kotlin service is unwrapped to that Kotlin service, with no `dispatcher` hop.
142-
* Resumption works across the adapter (`EventActions.agentState` crosses), but a rewind reverts
143-
* only state and artifacts: ADK Java's `EventActions` has no `rewindBeforeInvocationId`, so the
144-
* rewound turns stay in the model's history.
142+
* Resumption works across the adapter (`EventActions.agentState` crosses);
143+
* `rewindBeforeInvocationId` crosses too, but a rewind drops the rewound turns only if the Java
144+
* service stores it, which ADK Java's `VertexAiSessionService` does not.
145145
*/
146146
@JvmStatic
147147
@JvmOverloads

‎tokt/src/main/kotlin/com/google/adk/tokt/adapters/ActionReconciler.kt‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ import com.google.adk.tokt.codecs.reconcileRemovedSentinels
2626
/**
2727
* Copies what a bridged tool or plugin callback wrote on its [JavaEventActions] onto the live
2828
* [KtEventActions] the engine reads. Fields the live view already writes through are re-applied
29-
* idempotently; skip-summarization, tool confirmations, agentState and compaction are carried here.
30-
* A signal the engine cannot represent (requestedAuthConfigs, deletedArtifactIds) throws.
29+
* idempotently; skip-summarization, tool confirmations, agentState, compaction and
30+
* rewindBeforeInvocationId are carried here. A signal the engine cannot represent
31+
* (requestedAuthConfigs, deletedArtifactIds) throws.
3132
*/
3233
internal fun reconcileActionsToKt(java: JavaEventActions, kt: KtEventActions) {
3334
require(java.requestedAuthConfigs().isNullOrEmpty()) {
@@ -41,6 +42,7 @@ internal fun reconcileActionsToKt(java: JavaEventActions, kt: KtEventActions) {
4142
java.skipSummarization().ifPresent { kt.skipSummarization = it }
4243
java.agentState().ifPresent { kt.agentState = agentStateFromJava(it) }
4344
java.compaction().ifPresent { kt.compaction = EventCompactionCodec.fromJava(it) }
45+
java.rewindBeforeInvocationId().ifPresent { kt.rewindBeforeInvocationId = it }
4446
kt.endOfAgent = kt.endOfAgent || java.endOfAgent()
4547
// Merge deltas only when the tool replaced the actions (else they are the same live Kotlin map).
4648
if (java.stateDelta() !== kt.stateDelta) kt.stateDelta.putAll(java.stateDelta())

‎tokt/src/main/kotlin/com/google/adk/tokt/codecs/EventCodec.kt‎

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,15 @@ import kotlin.jvm.optionals.getOrNull
3131
*
3232
* Carries the identity (`id`, `invocationId`, `author`, `branch`), the [content][ContentCodec], the
3333
* [actions][KtEventActions] (state/artifact deltas, the control-flow flags - transfer, escalate,
34-
* skipSummarization, endOfAgent - the requested tool confirmations, and the context-compaction
35-
* summary), the streaming/error signals (`partial`, `turnComplete`, `interrupted`, `errorMessage`,
36-
* `errorCode`, `finishReason`), the token `usageMetadata` ([UsageMetadataCodec]) and
37-
* `groundingMetadata` ([GroundingMetadataCodec]), long-running tool ids, and the timestamp.
34+
* skipSummarization, endOfAgent - the requested tool confirmations, the context-compaction summary,
35+
* the resumable-run `agentState`, and the rewind marker `rewindBeforeInvocationId`), the
36+
* streaming/error signals (`partial`, `turnComplete`, `interrupted`, `errorMessage`, `errorCode`,
37+
* `finishReason`), the token `usageMetadata` ([UsageMetadataCodec]) and `groundingMetadata`
38+
* ([GroundingMetadataCodec]), long-running tool ids, and the timestamp.
3839
*/
3940
internal object EventCodec {
4041

41-
/**
42-
* Returns the Java [JavaEvent] view of the Kotlin [event].
43-
*
44-
* `EventActions.agentState` crosses (see [agentStateToJava]), so a resumable run's state survives
45-
* a round trip through a Java session service. `rewindBeforeInvocationId` is still dropped: ADK
46-
* Java's `EventActions` has no such field.
47-
*/
42+
/** Returns the Java [JavaEvent] view of the Kotlin [event]. */
4843
fun toJava(event: KtEvent): JavaEvent {
4944
val builder =
5045
JavaEvent.builder()
@@ -98,6 +93,7 @@ internal object EventCodec {
9893
compaction =
9994
javaActions.compaction().getOrNull()?.let { EventCompactionCodec.fromJava(it) },
10095
agentState = agentStateFromJava(javaActions.agentState().getOrNull()),
96+
rewindBeforeInvocationId = javaActions.rewindBeforeInvocationId().getOrNull(),
10197
),
10298
branch = event.branch().getOrNull(),
10399
partial = event.partial().getOrNull() ?: false,

‎tokt/src/main/kotlin/com/google/adk/tokt/codecs/KtToJavaCodecs.kt‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ internal class KtEventActionsToJavaView(private val actions: KtEventActions) : J
7373
actions.compaction = compaction?.let { EventCompactionCodec.fromJava(it) }
7474
}
7575

76+
override fun rewindBeforeInvocationId(): Optional<String> =
77+
Optional.ofNullable(actions.rewindBeforeInvocationId)
78+
79+
override fun setRewindBeforeInvocationId(rewindBeforeInvocationId: String?) {
80+
actions.rewindBeforeInvocationId = rewindBeforeInvocationId
81+
}
82+
7683
// Overridden so the write reaches the Kotlin side; the base setters write private fields the
7784
// overridden getters never read.
7885

@@ -104,12 +111,10 @@ internal class KtEventActionsToJavaView(private val actions: KtEventActions) : J
104111
/**
105112
* Builds a read-only Java [JavaEventActions] snapshot from a Kotlin [KtEventActions], carrying the
106113
* deltas, the control-flow signals (skip-summarization, transfer, escalate, end-of-agent), the
107-
* requested tool confirmations, and the resumable-run `agentState` ([agentStateToJava]). Used when
108-
* exposing an existing Kotlin event to a Java runner ([EventCodec.toJava]); unlike
109-
* [KtEventActionsToJavaView] (a live write sink) nothing writes back through it.
110-
*
111-
* Does not carry `rewindBeforeInvocationId`: ADK Java's `EventActions` has no such field, so a
112-
* rewind request does not survive a round trip through a Java session service.
114+
* requested tool confirmations, the compaction summary, the resumable-run `agentState`
115+
* ([agentStateToJava]), and the rewind marker (`rewindBeforeInvocationId`). Used when exposing an
116+
* existing Kotlin event to a Java runner ([EventCodec.toJava]); unlike [KtEventActionsToJavaView]
117+
* (a live write sink), nothing writes back through it.
113118
*/
114119
internal fun eventActionsToJava(actions: KtEventActions): JavaEventActions =
115120
JavaEventActions.builder()
@@ -124,6 +129,7 @@ internal fun eventActionsToJava(actions: KtEventActions): JavaEventActions =
124129
.endOfAgent(actions.endOfAgent)
125130
.compaction(actions.compaction?.let { EventCompactionCodec.toJava(it) })
126131
.agentState(agentStateToJava(actions.agentState))
132+
.rewindBeforeInvocationId(actions.rewindBeforeInvocationId)
127133
.build()
128134

129135
/** Copies a Kotlin state delta to Java, mapping the Kotlin [KtState.REMOVED] deletion sentinel. */

‎tokt/src/test/kotlin/com/google/adk/tokt/KtRunnerInteropTest.kt‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,42 @@ class KtRunnerInteropTest {
921921
)
922922
}
923923

924+
@Test
925+
fun ktRunner_rewindThroughAnAdaptedJavaSessionService_dropsTheRewoundTurnFromTheNextPrompt() =
926+
runBlocking {
927+
// Without the marker surviving the Java store, the engine keeps sending the rewound turn.
928+
val javaSessions = JavaInMemorySessionService()
929+
val model = SequentialJavaModel(listOf(modelText("ok")))
930+
val runner =
931+
KtInMemoryRunner(
932+
app =
933+
KtApp(
934+
appName = "app",
935+
rootAgent = KtLlmAgent(name = "a", model = JavaAdkToKt.asKtModel(model)),
936+
),
937+
sessionService = JavaAdkToKt.asKtSessionService(javaSessions),
938+
)
939+
runner.turn("keep me")
940+
val rewound = assertNotNull(runner.turn("forget me").first().invocationId)
941+
942+
runner.rewindAsync(userId = "u", sessionId = "s", rewindBeforeInvocationId = rewound)
943+
runner.turn("after the rewind")
944+
945+
val stored =
946+
javaSessions.getSession("app", "u", "s", Optional.empty()).blockingGet()
947+
?: fail("the adapted Java service should hold the session")
948+
assertTrue(
949+
stored.events().any { it.actions().rewindBeforeInvocationId().getOrNull() == rewound },
950+
"the stored rewind event should keep its marker",
951+
)
952+
val lastPrompt =
953+
model.requests.last().contents().flatMap { content ->
954+
content.parts().getOrNull().orEmpty().mapNotNull { it.text().getOrNull() }
955+
}
956+
assertContains(lastPrompt, "keep me", "the turn before the rewind should stay in the prompt")
957+
assertFalse("forget me" in lastPrompt, "the rewound turn must not reach the model")
958+
}
959+
924960
@Test
925961
fun ktRunner_pluginBeforeToolCallback_firesForNativeKotlinTool_andCanDenyIt() = runBlocking {
926962
// A native Kotlin tool has no Java form, yet a Java plugin's beforeToolCallback must still
@@ -3491,6 +3527,19 @@ class KtRunnerInteropTest {
34913527
)
34923528
}
34933529

3530+
@Test
3531+
fun eventCodec_rewindBeforeInvocationId_roundTripsThroughTheJavaEvent() {
3532+
// Wiring check: without both directions, a Java session service drops the rewind marker.
3533+
val ktEvent =
3534+
KtEvent(author = "user", actions = KtEventActions(rewindBeforeInvocationId = "inv1"))
3535+
3536+
assertEquals(
3537+
"inv1",
3538+
EventCodec.fromJava(EventCodec.toJava(ktEvent)).actions.rewindBeforeInvocationId,
3539+
"rewindBeforeInvocationId must survive EventCodec.toJava -> fromJava",
3540+
)
3541+
}
3542+
34943543
@Test
34953544
fun schemaCodec_carriesEveryFacet_roundTrips() {
34963545
// Every facet the Kotlin Schema models must survive Java -> Kotlin -> Java, or structured
@@ -3917,6 +3966,31 @@ class KtRunnerInteropTest {
39173966
assertTrue(kt.endOfAgent, "setEndInvocation should reach the Kotlin actions")
39183967
}
39193968

3969+
@Test
3970+
fun ktEventActionsView_rewindBeforeInvocationId_readsAndWritesThrough() {
3971+
// A marker set in place by a Java tool must reach the engine's actions.
3972+
val kt = KtEventActions(rewindBeforeInvocationId = "inv1")
3973+
val view = KtEventActionsToJavaView(kt)
3974+
3975+
assertEquals(
3976+
"inv1",
3977+
view.rewindBeforeInvocationId().getOrNull(),
3978+
"the view should read through",
3979+
)
3980+
view.setRewindBeforeInvocationId("inv2")
3981+
assertEquals("inv2", kt.rewindBeforeInvocationId, "the view should write through")
3982+
}
3983+
3984+
@Test
3985+
fun reconcileActions_carriesRewindBeforeInvocationId() {
3986+
// A Java tool that replaces its actions (setActions) must still carry the marker.
3987+
val kt = KtEventActions()
3988+
3989+
reconcileActionsToKt(JavaEventActions.builder().rewindBeforeInvocationId("inv1").build(), kt)
3990+
3991+
assertEquals("inv1", kt.rewindBeforeInvocationId)
3992+
}
3993+
39203994
@Test
39213995
fun stateValueFromJava_nullMapsToRemovalSentinel() {
39223996
// ADK Java treats a null state value as a removal; the bridge must not NPE on the runAsync

0 commit comments

Comments
 (0)