Skip to content

Commit 776351a

Browse files
kvmiloscopybara-github
authored andcommitted
feat(sessions): support session expiry in VertexAiSessionService
`VertexAiSessionService` now takes a `sessionTtl` applied to every session it creates, including the ones a runner creates through the `SessionService` interface, where a per-call argument cannot reach. A Vertex-only `createSession` overload additionally takes `ttl` or `expireTime` for one session, overriding that default. At most one expiry may be set, since the wire field is a single choice. Without one the backend applies its own default, which is long; with one, the minimum it accepts is 24 hours. The `SessionService` interface is unchanged. PiperOrigin-RevId: 970575360
1 parent e856bce commit 776351a

5 files changed

Lines changed: 298 additions & 18 deletions

File tree

core/src/jvmMain/kotlin/com/google/adk/kt/sessions/VertexAiSessionService.kt

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ import com.google.adk.kt.sessions.dto.toDto
2424
import com.google.auth.oauth2.GoogleCredentials
2525
import io.ktor.client.HttpClient
2626
import io.ktor.client.engine.java.Java
27+
import java.time.Duration as JavaDuration
2728
import kotlin.jvm.JvmStatic
29+
import kotlin.time.Duration
30+
import kotlin.time.Instant
31+
import kotlin.time.toKotlinDuration
2832

2933
/**
3034
* A [SessionService] backed by the managed Vertex AI Session Service.
@@ -51,6 +55,7 @@ internal constructor(
5155
private val project: String,
5256
private val location: String,
5357
private val reasoningEngineId: String,
58+
private val sessionTtl: Duration? = null,
5459
) : SessionService {
5560

5661
init {
@@ -59,6 +64,9 @@ internal constructor(
5964
"reasoningEngineId must be the numeric reasoning engine id (e.g. \"1234567890\"), not a" +
6065
" resource name; pass project and location as separate arguments. Got: $reasoningEngineId"
6166
}
67+
require(sessionTtl == null || sessionTtl.inWholeSeconds > 0) {
68+
"sessionTtl must be at least one second, but was $sessionTtl."
69+
}
6270
}
6371

6472
private val engine = ReasoningEngineRef(project, location, reasoningEngineId)
@@ -72,22 +80,59 @@ internal constructor(
7280
* @param credentials Credentials for the Vertex AI API; defaults to application-default
7381
* credentials scoped for Google Cloud Platform.
7482
* @param httpClient The underlying ktor [HttpClient].
83+
* @param sessionTtl Lifetime applied to every session this service creates, including those the
84+
* runner creates through the [SessionService] interface. A per-call `ttl` or `expireTime`
85+
* overrides it; `null` leaves the backend default in place.
7586
*/
7687
constructor(
7788
project: String,
7889
location: String,
7990
reasoningEngineId: String,
8091
credentials: GoogleCredentials = GoogleApiClient.defaultCredentials(),
8192
httpClient: HttpClient = HttpClient(Java),
93+
sessionTtl: Duration? = null,
8294
) : this(
8395
VertexAiSessionsClient(GoogleApiClient(httpClient, credentials)),
8496
project,
8597
location,
8698
reasoningEngineId,
99+
sessionTtl,
87100
)
88101

89-
override suspend fun createSession(key: SessionKey, state: Map<String, Any>?): Session {
90-
val sessionDto = client.createSession(engine, key.userId, state).getOrThrow()
102+
override suspend fun createSession(key: SessionKey, state: Map<String, Any>?): Session =
103+
createSession(key, state, ttl = null, expireTime = null)
104+
105+
/**
106+
* Creates a session that expires, addressing the reasoning engine fixed at construction.
107+
*
108+
* At most one of [ttl] and [expireTime] may be set, because the backend models them as a single
109+
* choice; setting both is rejected. The backend also requires the expiry to be at least 24 hours
110+
* out. When neither is given, the service-wide `sessionTtl` applies if one was configured.
111+
*
112+
* @param key The composite identifier of the session; [SessionKey.appName] is only a label.
113+
* @param state An optional map representing the initial state of the session.
114+
* @param ttl How long the session lives, measured from creation. Sub-second precision is dropped.
115+
* @param expireTime The absolute instant at which the session expires.
116+
* @return The newly created [Session].
117+
*/
118+
suspend fun createSession(
119+
key: SessionKey,
120+
state: Map<String, Any>? = null,
121+
ttl: Duration? = null,
122+
expireTime: Instant? = null,
123+
): Session {
124+
require(ttl == null || expireTime == null) {
125+
"Cannot specify both ttl and expireTime simultaneously."
126+
}
127+
// The wire format is whole seconds, so a sub-second ttl would silently travel as "0s".
128+
require(ttl == null || ttl.inWholeSeconds > 0) {
129+
"ttl must be at least one second, but was $ttl."
130+
}
131+
// A per-call arm wins; otherwise the service-wide default applies, so a session created
132+
// through the SessionService interface still expires.
133+
val effectiveTtl = if (ttl == null && expireTime == null) sessionTtl else ttl
134+
val sessionDto =
135+
client.createSession(engine, key.userId, state, effectiveTtl, expireTime).getOrThrow()
91136
return sessionDto.toAdk(key.appName, key.userId, key.id)
92137
}
93138

@@ -168,6 +213,7 @@ internal constructor(
168213
private var reasoningEngineId: String? = null
169214
private var credentials: GoogleCredentials? = null
170215
private var httpClient: HttpClient? = null
216+
private var sessionTtl: Duration? = null
171217

172218
fun project(project: String): Builder = apply { this.project = project }
173219

@@ -183,6 +229,16 @@ internal constructor(
183229

184230
fun httpClient(httpClient: HttpClient): Builder = apply { this.httpClient = httpClient }
185231

232+
/**
233+
* Sets the lifetime applied to every session this service creates.
234+
*
235+
* Takes a [JavaDuration] because Java cannot name a method whose signature contains [Duration],
236+
* a value class.
237+
*/
238+
fun sessionTtl(sessionTtl: JavaDuration): Builder = apply {
239+
this.sessionTtl = sessionTtl.toKotlinDuration()
240+
}
241+
186242
fun build(): VertexAiSessionService =
187243
VertexAiSessionService(
188244
project =
@@ -195,6 +251,7 @@ internal constructor(
195251
},
196252
credentials = credentials ?: GoogleApiClient.defaultCredentials(),
197253
httpClient = httpClient ?: HttpClient(Java),
254+
sessionTtl = sessionTtl,
198255
)
199256
}
200257

core/src/jvmMain/kotlin/com/google/adk/kt/sessions/VertexAiSessionsClient.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import java.io.IOException
3838
import java.net.URLEncoder
3939
import java.nio.charset.StandardCharsets
4040
import java.util.concurrent.TimeoutException
41+
import kotlin.time.Duration
42+
import kotlin.time.Instant
4143
import kotlinx.coroutines.delay
4244
import kotlinx.serialization.DeserializationStrategy
4345

@@ -101,17 +103,28 @@ internal open class VertexAiSessionsClient(
101103
* @param engine The reasoning engine that owns the session.
102104
* @param userId The ID of the user owning the session.
103105
* @param state Optional initial state map to inject into the session.
106+
* @param ttl Optional lifetime of the session, measured from creation.
107+
* @param expireTime Optional absolute expiry of the session. The wire field is a oneof, so the
108+
* caller must not set both this and [ttl].
104109
* @return The created [SessionDto], or a [Result.failure] describing why creation failed.
105110
*/
106111
open suspend fun createSession(
107112
engine: ReasoningEngineRef,
108113
userId: String,
109114
state: Map<String, Any>?,
115+
ttl: Duration? = null,
116+
expireTime: Instant? = null,
110117
): Result<SessionDto> {
111118
val requestBody =
112119
adkJson.encodeToString(
113120
CreateSessionRequestDto.serializer(),
114-
CreateSessionRequestDto(userId = userId, sessionState = state?.let { anyToJsonElement(it) }),
121+
CreateSessionRequestDto(
122+
userId = userId,
123+
sessionState = state?.let { anyToJsonElement(it) },
124+
// proto3 JSON writes a Duration as seconds with an "s" suffix.
125+
ttl = ttl?.let { "${it.inWholeSeconds}s" },
126+
expireTime = expireTime?.toString(),
127+
),
115128
)
116129
val createResponse =
117130
postAndDecode(

core/src/jvmMain/kotlin/com/google/adk/kt/sessions/dto/ResponsesDto.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,7 @@ internal data class ListEventsResponseDto(
3838
internal data class CreateSessionRequestDto(
3939
val userId: String,
4040
val sessionState: JsonElement? = null,
41+
// `ttl`/`expire_time` are a proto oneof; at most one is set, in its JSON string form.
42+
val ttl: String? = null,
43+
val expireTime: String? = null,
4144
)

core/src/jvmTest/kotlin/com/google/adk/kt/sessions/VertexAiSessionServiceTest.kt

Lines changed: 158 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ import com.google.adk.kt.types.Part
2828
import com.google.common.truth.Truth.assertThat
2929
import java.io.IOException
3030
import kotlin.test.assertFailsWith
31+
import kotlin.time.Duration
32+
import kotlin.time.Duration.Companion.hours
33+
import kotlin.time.Duration.Companion.milliseconds
34+
import kotlin.time.Duration.Companion.seconds
3135
import kotlin.time.Instant
36+
import kotlinx.coroutines.runBlocking
3237
import kotlinx.coroutines.test.runTest
3338
import kotlinx.serialization.json.JsonObject
3439
import kotlinx.serialization.json.JsonPrimitive
@@ -54,27 +59,33 @@ import org.mockito.kotlin.verifyBlocking
5459
@RunWith(JUnit4::class)
5560
class VertexAiSessionServiceTest {
5661

57-
private fun service(client: VertexAiSessionsClient) =
62+
private fun service(client: VertexAiSessionsClient, sessionTtl: Duration? = null) =
5863
VertexAiSessionService(
5964
client,
6065
project = PROJECT,
6166
location = LOCATION,
6267
reasoningEngineId = ENGINE_ID,
68+
sessionTtl = sessionTtl,
6369
)
6470

71+
/** A client that accepts any create call, for asserting which expiration was forwarded. */
72+
private fun expiringSessionClient() =
73+
mock<VertexAiSessionsClient> {
74+
onBlocking { createSession(any(), any(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn
75+
Result.success(SessionDto(name = "reasoningEngines/123/sessions/s"))
76+
}
77+
6578
@Test
6679
fun addressesConfiguredEngineRegardlessOfAppName() = runTest {
67-
val client =
68-
mock<VertexAiSessionsClient> {
69-
onBlocking { createSession(any(), any(), anyOrNull()) } doReturn
70-
Result.success(SessionDto(name = "reasoningEngines/123/sessions/s"))
71-
}
80+
val client = expiringSessionClient()
7281

7382
// The app name is only a label; the service always addresses the engine set at construction.
7483
val unused =
7584
service(client).createSession(SessionKey("any-label", "user", id = null), state = null)
7685

77-
verifyBlocking(client) { createSession(eq(ENGINE), eq("user"), anyOrNull()) }
86+
verifyBlocking(client) {
87+
createSession(eq(ENGINE), eq("user"), anyOrNull(), anyOrNull(), anyOrNull())
88+
}
7889
}
7990

8091
@Test
@@ -137,7 +148,9 @@ class VertexAiSessionServiceTest {
137148
fun createSession_mapsClientResponse() = runTest {
138149
val client =
139150
mock<VertexAiSessionsClient> {
140-
onBlocking { createSession(eq(ENGINE), eq("user"), anyOrNull()) } doReturn
151+
onBlocking {
152+
createSession(eq(ENGINE), eq("user"), anyOrNull(), anyOrNull(), anyOrNull())
153+
} doReturn
141154
Result.success(
142155
SessionDto(
143156
name = "reasoningEngines/123/sessions/session-1",
@@ -160,7 +173,7 @@ class VertexAiSessionServiceTest {
160173
fun createSession_clientFails_propagates() = runTest {
161174
val client =
162175
mock<VertexAiSessionsClient> {
163-
onBlocking { createSession(any(), any(), anyOrNull()) } doReturn
176+
onBlocking { createSession(any(), any(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn
164177
Result.failure(IOException("boom"))
165178
}
166179

@@ -169,6 +182,142 @@ class VertexAiSessionServiceTest {
169182
}
170183
}
171184

185+
@Test
186+
fun createSession_ttl_forwardsTtlOnly() {
187+
val client = expiringSessionClient()
188+
189+
runBlocking {
190+
val unused =
191+
service(client).createSession(SessionKey("123", "user", id = null), ttl = 24.hours)
192+
}
193+
194+
verifyBlocking(client) {
195+
createSession(eq(ENGINE), eq("user"), anyOrNull(), eq(24.hours), eq(null))
196+
}
197+
}
198+
199+
@Test
200+
fun createSession_expireTime_forwardsExpireTimeOnly() {
201+
val client = expiringSessionClient()
202+
val expiry = Instant.parse("2026-10-01T00:00:00Z")
203+
204+
runBlocking {
205+
val unused =
206+
service(client).createSession(SessionKey("123", "user", id = null), expireTime = expiry)
207+
}
208+
209+
verifyBlocking(client) {
210+
createSession(eq(ENGINE), eq("user"), anyOrNull(), eq(null), eq(expiry))
211+
}
212+
}
213+
214+
@Test
215+
fun createSession_noExpiration_forwardsNeither() {
216+
val client = expiringSessionClient()
217+
218+
// The SessionService overload must not invent an expiration of its own.
219+
runBlocking {
220+
val unused = service(client).createSession(SessionKey("123", "user", id = null))
221+
}
222+
223+
verifyBlocking(client) {
224+
createSession(eq(ENGINE), eq("user"), anyOrNull(), eq(null), eq(null))
225+
}
226+
}
227+
228+
@Test
229+
fun createSession_ttlAndExpireTime_throwsWithoutCallingBackend() {
230+
val client = expiringSessionClient()
231+
232+
assertFailsWith<IllegalArgumentException> {
233+
runBlocking {
234+
service(client)
235+
.createSession(
236+
SessionKey("123", "user", id = null),
237+
ttl = 24.hours,
238+
expireTime = Instant.parse("2026-10-01T00:00:00Z"),
239+
)
240+
}
241+
}
242+
verifyBlocking(client, never()) {
243+
createSession(any(), any(), anyOrNull(), anyOrNull(), anyOrNull())
244+
}
245+
}
246+
247+
@Test
248+
fun createSession_serviceTtl_appliedThroughSessionServiceInterface() {
249+
val client = expiringSessionClient()
250+
// The runner and the web server create sessions through the interface, where the per-call
251+
// overload is unreachable, so the configured default has to reach them.
252+
val sessionService: SessionService = service(client, sessionTtl = 24.hours)
253+
254+
runBlocking {
255+
val unused = sessionService.createSession(SessionKey("123", "user", id = null))
256+
}
257+
258+
verifyBlocking(client) {
259+
createSession(eq(ENGINE), eq("user"), anyOrNull(), eq(24.hours), eq(null))
260+
}
261+
}
262+
263+
@Test
264+
fun createSession_perCallTtl_overridesServiceTtl() {
265+
val client = expiringSessionClient()
266+
267+
runBlocking {
268+
val unused =
269+
service(client, sessionTtl = 24.hours)
270+
.createSession(SessionKey("123", "user", id = null), ttl = 48.hours)
271+
}
272+
273+
verifyBlocking(client) {
274+
createSession(eq(ENGINE), eq("user"), anyOrNull(), eq(48.hours), eq(null))
275+
}
276+
}
277+
278+
@Test
279+
fun createSession_perCallExpireTime_suppressesServiceTtl() {
280+
val client = expiringSessionClient()
281+
val expiry = Instant.parse("2026-10-01T00:00:00Z")
282+
283+
// Both arms are a single wire choice, so the default must not ride along with expireTime.
284+
runBlocking {
285+
val unused =
286+
service(client, sessionTtl = 24.hours)
287+
.createSession(SessionKey("123", "user", id = null), expireTime = expiry)
288+
}
289+
290+
verifyBlocking(client) {
291+
createSession(eq(ENGINE), eq("user"), anyOrNull(), eq(null), eq(expiry))
292+
}
293+
}
294+
295+
@Test
296+
fun constructor_sessionTtlBelowOneSecond_throws() {
297+
for (bad in listOf(Duration.ZERO, (-1).seconds, 500.milliseconds)) {
298+
assertFailsWith<IllegalArgumentException> {
299+
service(mock<VertexAiSessionsClient>(), sessionTtl = bad)
300+
}
301+
}
302+
}
303+
304+
@Test
305+
fun createSession_ttlBelowOneSecond_throwsWithoutCallingBackend() {
306+
val client = expiringSessionClient()
307+
308+
// 500ms is positive but truncates to "0s" on the wire, so it must be rejected too.
309+
for (bad in listOf(Duration.ZERO, (-1).seconds, 500.milliseconds)) {
310+
assertFailsWith<IllegalArgumentException> {
311+
runBlocking {
312+
service(client).createSession(SessionKey("123", "user", id = null), ttl = bad)
313+
}
314+
}
315+
}
316+
verifyBlocking(client, never()) {
317+
createSession(any(), any(), anyOrNull(), anyOrNull(), anyOrNull())
318+
}
319+
}
320+
172321
@Test
173322
fun getSession_notFound_returnsNull() = runTest {
174323
val client =

0 commit comments

Comments
 (0)