diff --git a/build.gradle.kts b/build.gradle.kts index 9dc9dcf7e..1c916cfef 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,6 +36,7 @@ tasks.register("runDebugUnitTest") { dependsOn(":instantsearch:testDebugUnitTest") dependsOn(":instantsearch-insights:testDebugUnitTest") dependsOn(":instantsearch-compose:testDebugUnitTest") + dependsOn(":instantsearch-agent:jvmTest") dependsOn(":extensions:android-paging3:testDebugUnitTest") dependsOn(":extensions:android-loading:testDebugUnitTest") dependsOn(":extensions:coroutines-extensions:jvmTest") diff --git a/examples/android/build.gradle b/examples/android/build.gradle index db62c7e76..09f32cda4 100644 --- a/examples/android/build.gradle +++ b/examples/android/build.gradle @@ -58,6 +58,8 @@ dependencies { implementation project(":extensions:android-paging3") //implementation "com.algolia:instantsearch-android-loading:$instantsearch" implementation project(":extensions:android-loading") + // Experimental, standalone agent SDK (versioned independently, 0.x). + implementation project(":instantsearch-agent") implementation "com.algolia.instantsearch:voice:1.1.0" implementation "androidx.fragment:fragment-ktx:1.5.4" @@ -91,6 +93,7 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { '-Xopt-in=com.algolia.instantsearch.ExperimentalInstantSearch', '-Xopt-in=androidx.compose.material.ExperimentalMaterialApi', '-opt-in=kotlinx.serialization.InternalSerializationApi', + '-opt-in=com.algolia.instantsearch.agent.ExperimentalAgentStudioApi', ] } } diff --git a/examples/android/src/main/AndroidManifest.xml b/examples/android/src/main/AndroidManifest.xml index 464aeec04..f786b5f32 100644 --- a/examples/android/src/main/AndroidManifest.xml +++ b/examples/android/src/main/AndroidManifest.xml @@ -206,6 +206,12 @@ android:name=".showcase.compose.filter.facet.DynamicFacetShowcase" android:parentActivityName=".showcase.compose.directory.ComposeDirectoryShowcase" /> + + + + >) = response.hits.deserialize(DirectoryHit.serializer()) .filter { mappings.containsKey(it.objectID) } @@ -44,7 +57,7 @@ internal fun directoryItems(response: SearchResponse, mappings: Map listOf(DirectoryItem.Header(key)) + value.map { DirectoryItem.Item(it, mappings.getValue(it.objectID)) } .sortedBy { it.hit.objectID } - } + } + experimentalItems internal fun Context.navigateTo(item: DirectoryItem.Item) { val intent = Intent(this, item.dest.java).apply { diff --git a/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/directory/DirectoryActivity.kt b/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/directory/DirectoryActivity.kt index 78d5726d2..2d8f0b9bb 100644 --- a/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/directory/DirectoryActivity.kt +++ b/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/directory/DirectoryActivity.kt @@ -26,6 +26,7 @@ class DirectoryActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_directory) + connection += connector.connectView(SearchBoxViewAppCompat(findViewById(R.id.searchView))) val adapter = DirectoryAdapter() diff --git a/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/showcase/compose/agent/AgentChatViewModel.kt b/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/showcase/compose/agent/AgentChatViewModel.kt new file mode 100644 index 000000000..ae8db1018 --- /dev/null +++ b/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/showcase/compose/agent/AgentChatViewModel.kt @@ -0,0 +1,40 @@ +package com.algolia.instantsearch.examples.android.showcase.compose.agent + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.algolia.instantsearch.agent.chat.ChatStore +import com.algolia.instantsearch.agent.transport.AgentStudioTransport + +/** + * Owns the [ChatStore] for the Agent Studio showcase. + * + * Reuses the same Agent Studio showcase config that ships with the InstantSearch + * web examples (`examples/js/showcase`), so the demo works out of the box. + * In a real app, pass a **search-only** API key — never an admin key. + */ +class AgentChatViewModel : ViewModel() { + + val store: ChatStore = run { + val transport = AgentStudioTransport.fromCredentials( + appId = APP_ID, + apiKey = SEARCH_API_KEY, + agentId = AGENT_ID, + ) + ChatStore(transport = transport, scope = viewModelScope) + } + + fun send(text: String) = store.send(text) + + fun stop() = store.stop() + + override fun onCleared() { + super.onCleared() + store.stop() + } + + private companion object { + const val APP_ID = "latency" + const val SEARCH_API_KEY = "6be0576ff61c053d5f9a3225e2a90f76" + const val AGENT_ID = "eedef238-5468-470d-bc37-f99fa741bd25" + } +} diff --git a/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/showcase/compose/agent/AgentStudioShowcase.kt b/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/showcase/compose/agent/AgentStudioShowcase.kt new file mode 100644 index 000000000..c31867837 --- /dev/null +++ b/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/showcase/compose/agent/AgentStudioShowcase.kt @@ -0,0 +1,334 @@ +package com.algolia.instantsearch.examples.android.showcase.compose.agent + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Send +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.algolia.instantsearch.agent.model.ChatStatus +import com.algolia.instantsearch.agent.model.MessageRole +import com.algolia.instantsearch.agent.model.ToolCallState +import com.algolia.instantsearch.agent.model.ToolUIPart +import com.algolia.instantsearch.agent.model.UIMessage +import com.algolia.instantsearch.agent.model.UIMessagePart +import com.algolia.instantsearch.examples.android.showcase.compose.ui.ShowcaseTheme +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +/** + * Showcase for the experimental, standalone `instantsearch-agent` SDK. + * + * Demonstrates the minimal flow: build an [com.algolia.instantsearch.agent.transport.AgentStudioTransport] + * from credentials, drive a [com.algolia.instantsearch.agent.chat.ChatStore] from a + * [androidx.lifecycle.ViewModel], and render its observable state with Compose. + * + * It reuses the Agent Studio showcase agent that ships with the InstantSearch + * web examples, so it works out of the box with no setup. + */ +class AgentStudioShowcase : AppCompatActivity() { + + private val viewModel: AgentChatViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + ShowcaseTheme { + AgentScreen(viewModel) + } + } + } +} + +@Composable +private fun AgentScreen(viewModel: AgentChatViewModel) { + val store = viewModel.store + val messages by store.messages.collectAsState() + val status by store.status.collectAsState() + val error by store.error.collectAsState() + val suggestions by store.suggestions.collectAsState() + + var input by remember { mutableStateOf("") } + val listState = rememberLazyListState() + + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex) + } + + Scaffold( + topBar = { TopAppBar(title = { Text("Agent Studio (experimental)") }) }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding), + ) { + LazyColumn( + state = listState, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(messages, key = { it.id }) { MessageRow(it) } + if (status == ChatStatus.Submitted || status == ChatStatus.Streaming) { + item { CircularProgressIndicator(Modifier.padding(8.dp)) } + } + } + + error?.let { + Text( + text = it.message ?: "Error", + color = MaterialTheme.colors.error, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + + // Prompt suggestions ("what to ask next"). Shown only while idle, as + // tappable chips; clicking one sends it as a new user message — the + // same behavior as the web Chat widget. + if (status == ChatStatus.Ready && suggestions.isNotEmpty()) { + LazyRow( + contentPadding = PaddingValues(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(suggestions, key = { it }) { suggestion -> + SuggestionChip(text = suggestion, onClick = { viewModel.send(suggestion) }) + } + } + } + + Row( + Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Ask anything…") }, + ) + Spacer(Modifier.width(8.dp)) + Button( + enabled = input.isNotBlank() && status == ChatStatus.Ready, + onClick = { + viewModel.send(input.trim()) + input = "" + }, + ) { Icon(Icons.Default.Send, contentDescription = "Send") } + if (status == ChatStatus.Streaming) { + IconButton(onClick = viewModel::stop) { + Icon(Icons.Default.Close, contentDescription = "Stop") + } + } + } + } + } +} + +@Composable +private fun SuggestionChip(text: String, onClick: () -> Unit) { + Card( + modifier = Modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, MaterialTheme.colors.primary.copy(alpha = 0.5f)), + elevation = 0.dp, + ) { + Text( + text = text, + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.primary, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } +} + +@Composable +private fun MessageRow(message: UIMessage) { + val prefix = if (message.role == MessageRole.User) "You" else "Assistant" + Column { + Text( + text = prefix, + style = MaterialTheme.typography.caption, + fontWeight = FontWeight.Bold, + ) + if (message.plainText.isNotEmpty()) { + Text(text = message.plainText) + } + message.parts.filterIsInstance().forEach { tool -> + ToolPart(tool.part) + } + } +} + +@Composable +private fun ToolPart(part: ToolUIPart) { + when (val state = part.state) { + is ToolCallState.OutputAvailable -> { + // Mirrors the web Chat widget: the `algolia_search_index` tool + // returns `output.hits`, which we render as a product carousel. + val products = if (isSearchTool(part.toolName)) { + extractProducts(state.output) + } else { + emptyList() + } + if (products.isNotEmpty()) { + ProductCarousel(products) + } else { + ToolStatusLabel(part.toolName, "done") + } + } + is ToolCallState.OutputError -> ToolStatusLabel(part.toolName, "error: ${state.errorText}") + is ToolCallState.InputAvailable -> ToolStatusLabel(part.toolName, "running…") + is ToolCallState.InputStreaming -> ToolStatusLabel(part.toolName, "preparing…") + } +} + +@Composable +private fun ToolStatusLabel(toolName: String, status: String) { + Text( + text = "🔧 $toolName • $status", + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + modifier = Modifier.padding(top = 2.dp), + ) +} + +@Composable +private fun ProductCarousel(products: List) { + LazyRow( + contentPadding = PaddingValues(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(products, key = { it.objectID }) { ProductCard(it) } + } +} + +@Composable +private fun ProductCard(product: Product) { + Card( + modifier = Modifier.width(140.dp), + shape = RoundedCornerShape(8.dp), + elevation = 2.dp, + ) { + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)) + .background(Color(0xFFF0F0F0)), + contentAlignment = Alignment.Center, + ) { + if (product.imageUrl != null) { + AsyncImage( + model = product.imageUrl, + contentDescription = product.name, + modifier = Modifier.size(120.dp), + ) + } else { + Text("🛍️") + } + } + Column(Modifier.padding(8.dp)) { + Text( + text = product.name, + style = MaterialTheme.typography.caption, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + product.price?.let { + Text( + text = it, + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.primary, + ) + } + } + } + } +} + +private data class Product( + val objectID: String, + val name: String, + val imageUrl: String?, + val price: String?, +) + +/** The web showcase treats `algolia_search_index` and `algolia_search_index_*` (MCP) as product search. */ +private fun isSearchTool(toolName: String): Boolean = + toolName == "algolia_search_index" || toolName.startsWith("algolia_search_index_") + +/** Reads `output.hits[]` from the search tool result, matching the web Chat widget. */ +private fun extractProducts(output: kotlinx.serialization.json.JsonElement): List { + val hits = (output as? JsonObject)?.get("hits") as? JsonArray ?: return emptyList() + return hits.mapNotNull { element -> + val hit = element as? JsonObject ?: return@mapNotNull null + val objectID = hit.string("objectID") ?: return@mapNotNull null + Product( + objectID = objectID, + name = hit.string("name") ?: hit.string("title") ?: objectID, + imageUrl = hit.string("image") ?: hit.string("image_url") ?: hit.string("thumbnailUrl"), + price = hit.number("price")?.let { "$$it" }, + ) + } +} + +private fun JsonObject.string(key: String): String? = + (this[key] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } + +private fun JsonObject.number(key: String): String? { + val primitive = this[key] as? JsonPrimitive ?: return null + return primitive.contentOrNull?.takeIf { it.isNotBlank() && it.toDoubleOrNull() != null } +} diff --git a/gradle.properties b/gradle.properties index e95fcba29..10b2e91ce 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,6 +9,14 @@ kotlin.mpp.stability.nowarn=true android.useAndroidX=true kotlin.mpp.androidSourceSetLayoutVersion=2 +# Use a newer Lint than the one bundled with AGP 8.7.2. AGP 8.7.x ships Lint +# detectors compiled against an older Kotlin Analysis API that crash under +# Kotlin 2.2.0 with `IncompatibleClassChangeError` (e.g. +# FrequentlyChangingValueDetector / NonNullableMutableLiveDataDetector). Lint +# 8.8.2 is built for the Kotlin 2.2 Analysis API and resolves the crash. +# See https://issuetracker.google.com/issues/418014548 +android.experimental.lint.version=8.8.2 + # Lib GROUP=com.algolia VERSION_NAME=4.0.2 diff --git a/instantsearch-agent/README.md b/instantsearch-agent/README.md new file mode 100644 index 000000000..7346653ea --- /dev/null +++ b/instantsearch-agent/README.md @@ -0,0 +1,169 @@ +# instantsearch-agent + +> [!WARNING] +> **Experimental.** This is a standalone, early-stage library versioned +> independently (`0.x`) from the main InstantSearch Android library (`4.x`). +> Its public API is annotated with `@ExperimentalAgentStudioApi` and can change +> in source- and binary-incompatible ways — including being renamed or +> removed — before a stable `1.0` release. It is a *beta feature* per +> [Algolia's Terms of Service ("Beta Services")](https://www.algolia.com/policies/terms/). + +Minimal native client for [Algolia Agent Studio][1]. Mirrors the AI SDK 5 +wire format used by `react-instantsearch`'s `` widget — without bundling +any chat UI. Bring-your-own Compose / View code. + +Full documentation: see the [InstantSearch Agent Studio guide][2] on the +Algolia docs. + +## Install + +`build.gradle.kts`: + +```kotlin +dependencies { + // Versioned independently from the main InstantSearch 4.x line. + implementation("com.algolia:instantsearch-agent:0.1.0") + implementation("io.ktor:ktor-client-okhttp:3.3.3") // or your preferred engine +} +``` + +## Opting in to the experimental API + +Every public declaration requires an explicit opt-in. Either annotate the +usage site with `@OptIn(ExperimentalAgentStudioApi::class)`, or opt in +module-wide in your `build.gradle.kts`: + +```kotlin +kotlin { + sourceSets.all { + languageSettings.optIn("com.algolia.instantsearch.agent.ExperimentalAgentStudioApi") + } +} +``` + +## Quick start (Jetpack Compose + ViewModel) + +```kotlin +@OptIn(ExperimentalAgentStudioApi::class) +class AgentChatViewModel( + appId: String, + apiKey: String, + agentId: String, +) : ViewModel() { + private val transport = AgentStudioTransport.fromCredentials( + appId = appId, + apiKey = apiKey, + agentId = agentId, + ) + val store = ChatStore(transport = transport, scope = viewModelScope) +} + +@Composable +fun AgentChatScreen(viewModel: AgentChatViewModel) { + val messages by viewModel.store.messages.collectAsState() + val status by viewModel.store.status.collectAsState() + val error by viewModel.store.error.collectAsState() + var input by remember { mutableStateOf("") } + + Column(Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(messages) { MessageRow(it) } + if (status == ChatStatus.Submitted || status == ChatStatus.Streaming) { + item { CircularProgressIndicator(Modifier.padding(8.dp)) } + } + } + + error?.let { + Text( + text = it.message ?: "Error", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + + Row(Modifier.padding(8.dp)) { + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Ask anything…") }, + ) + Spacer(Modifier.width(8.dp)) + Button( + enabled = input.isNotBlank() && status == ChatStatus.Ready, + onClick = { + viewModel.store.send(input.trim()) + input = "" + }, + ) { Text("Send") } + if (status == ChatStatus.Streaming) { + IconButton(onClick = viewModel.store::stop) { + Icon(Icons.Default.Close, contentDescription = "Stop") + } + } + } + } +} + +@Composable +private fun MessageRow(message: UIMessage) { + val prefix = if (message.role == MessageRole.User) "🧑" else "🤖" + Column { + Text("$prefix ${message.plainText}") + message.parts.filterIsInstance().forEach { tool -> + ToolCallChip(tool.part) + } + } +} + +@Composable +private fun ToolCallChip(part: ToolUIPart) { + val label = when (val state = part.state) { + is ToolCallState.InputStreaming -> "preparing…" + is ToolCallState.InputAvailable -> "running…" + is ToolCallState.OutputAvailable -> "done" + is ToolCallState.OutputError -> "error: ${state.errorText}" + } + AssistChip(onClick = {}, label = { Text("${part.toolName} • $label") }) +} +``` + +## What's in v0.1 + +| | | +|---|---| +| `AgentStudioEndpoint` | Builds the `agent-studio/1/agents/{id}/completions?compatibilityMode=ai-sdk-5` URL. | +| `AgentStudioTransport` | Ktor-backed POST + SSE response. | +| `SseEventStream` | `Flow` from a Ktor `ByteReadChannel`. | +| `ChatStore` | StateFlow-backed aggregator exposing `messages`, `status`, `error`, `send`/`regenerate`/`stop`/`clear`. | + +## What's not yet here (planned) + +- Drop-in Compose chat composable. +- Client-side tool execution (`onToolCall`). +- Conversation persistence (DataStore / Room). +- Suggestion data parts surfaced as a typed property. +- Feedback (👍 / 👎) endpoint. + +## Versioning + +This library has its **own version line** (`0.x`), independent of the main +InstantSearch Android library (`4.x`), so it can iterate quickly while +experimental. Breaking changes can land in any `0.x` release. The version is +set via this module's `gradle.properties` (`VERSION_NAME`) and does not affect +the rest of the InstantSearch artifacts. + +### Changelog + +#### 0.1.0 + +- Initial experimental release: `AgentStudioEndpoint`, `AgentStudioTransport`, + `SseEventStream`, and `ChatStore` with AI SDK 5 streaming support. + +[1]: https://www.algolia.com/doc/guides/algolia-ai/agent-studio/how-to/integration +[2]: https://www.algolia.com/doc/guides/algolia-ai/agent-studio/how-to/instantsearch-agent-android + diff --git a/instantsearch-agent/build.gradle.kts b/instantsearch-agent/build.gradle.kts new file mode 100644 index 000000000..268f9e49e --- /dev/null +++ b/instantsearch-agent/build.gradle.kts @@ -0,0 +1,109 @@ +plugins { + kotlin("multiplatform") + id("com.android.library") + id("kotlinx-serialization") + id("com.vanniktech.maven.publish") +} + +// Agent Studio is an EXPERIMENTAL, standalone library. It is versioned +// independently from the rest of InstantSearch (0.x line) via the +// module-local `gradle.properties` `VERSION_NAME` override. +group = providers.gradleProperty("GROUP").get() +version = providers.gradleProperty("VERSION_NAME").get() + +android { + namespace = "com.algolia.instantsearch.agent" + compileSdk = 35 + + defaultConfig { + minSdk = 23 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + testOptions.unitTests.apply { + isIncludeAndroidResources = true + isReturnDefaultValues = true + } + + sourceSets { + getByName("main") { + manifest.srcFile("src/androidMain/AndroidManifest.xml") + } + } + + resourcePrefix = "alg_is_agent_" +} + +kotlin { + explicitApi() + androidTarget() + jvm() + sourceSets { + all { + languageSettings { + optIn("kotlinx.serialization.ExperimentalSerializationApi") + optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") + // The module opts in to its own experimental marker so its + // internal sources don't have to annotate every call site. + // Consumers still have to opt in explicitly. + optIn("com.algolia.instantsearch.agent.ExperimentalAgentStudioApi") + } + } + + commonMain { + dependencies { + api(libs.kotlinx.coroutines.core) + api(libs.ktor.client.serialization.json) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1") + } + } + commonTest { + dependencies { + implementation(libs.test.kotlin.common) + implementation(libs.test.kotlin.annotations) + implementation(libs.test.coroutines) + implementation(libs.test.ktor.client.mock) + } + } + named("jvmMain") { + dependencies { + implementation(libs.ktor.client.okhttp) + } + } + named("jvmTest") { + dependencies { + implementation(libs.test.kotlin.junit) + } + } + named("androidMain") { + dependencies { + implementation(libs.ktor.client.okhttp) + implementation(libs.kotlinx.coroutines.android) + } + } + named("androidUnitTest") { + dependencies { + implementation(libs.test.kotlin.junit) + implementation(libs.test.androidx.runner) + implementation(libs.test.androidx.ext) + implementation(libs.test.robolectric) + } + } + } +} + +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8) + // Explicit-api strictness applies to production code only; test sources + // (e.g. JUnit `class …Test`) don't need explicit visibility modifiers. + if (!name.contains("Test")) { + freeCompilerArgs.addAll(listOf("-Xexplicit-api=strict")) + } + } +} diff --git a/instantsearch-agent/gradle.properties b/instantsearch-agent/gradle.properties new file mode 100644 index 000000000..90f98e53f --- /dev/null +++ b/instantsearch-agent/gradle.properties @@ -0,0 +1,10 @@ +# Agent Studio ships as a standalone, EXPERIMENTAL library with its own +# version line (0.x), decoupled from the main InstantSearch version (4.x). +# This overrides the root `VERSION_NAME` for this module only. +# The experimental status is conveyed through the `@ExperimentalAgentStudioApi` +# opt-in marker, not through the artifact version. +VERSION_NAME=0.1.0 + +POM_NAME=InstantSearch Agent +POM_ARTIFACT_ID=instantsearch-agent +POM_DESCRIPTION=Experimental, standalone client for Algolia Agent Studio on Android/Kotlin Multiplatform. Provides transport, streaming, and a chat state container for building agentic (conversational AI) search experiences. This API is experimental and may change in incompatible ways before a stable 1.0 release. diff --git a/instantsearch-agent/src/androidMain/AndroidManifest.xml b/instantsearch-agent/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..b2d3ea123 --- /dev/null +++ b/instantsearch-agent/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/ExperimentalAgentStudioApi.kt b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/ExperimentalAgentStudioApi.kt new file mode 100644 index 000000000..cb7fdcc2a --- /dev/null +++ b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/ExperimentalAgentStudioApi.kt @@ -0,0 +1,48 @@ +package com.algolia.instantsearch.agent + +/** + * Marks an API of the InstantSearch Agent Studio library as **experimental**. + * + * The Agent Studio library is a standalone, early-stage module versioned + * independently (0.x) from the main InstantSearch library. Anything annotated + * with [ExperimentalAgentStudioApi] can change in source- and binary- + * incompatible ways — including being renamed or removed — without a major + * version bump, until the library reaches a stable 1.0 release. + * + * To use an experimental declaration you must explicitly opt in, either by + * annotating the usage site: + * + * ``` + * @OptIn(ExperimentalAgentStudioApi::class) + * fun startChat() { /* ... */ } + * ``` + * + * or, for a whole module, by adding the compiler argument + * `-opt-in=com.algolia.instantsearch.agent.ExperimentalAgentStudioApi`. + * + * We deliberately keep this marker local to the Agent Studio module (rather + * than reusing `com.algolia.instantsearch.ExperimentalInstantSearch`) so the + * standalone library doesn't couple its opt-in surface to the main + * InstantSearch version. + */ +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.ANNOTATION_CLASS, + AnnotationTarget.PROPERTY, + AnnotationTarget.FIELD, + AnnotationTarget.LOCAL_VARIABLE, + AnnotationTarget.VALUE_PARAMETER, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, + AnnotationTarget.TYPEALIAS, +) +@Retention(AnnotationRetention.BINARY) +@RequiresOptIn( + level = RequiresOptIn.Level.WARNING, + message = "InstantSearch Agent Studio is experimental. This API can be changed " + + "incompatibly or removed in any release before 1.0. Opt in with " + + "@OptIn(ExperimentalAgentStudioApi::class) to acknowledge this.", +) +public annotation class ExperimentalAgentStudioApi diff --git a/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/chat/ChatStore.kt b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/chat/ChatStore.kt new file mode 100644 index 000000000..9e8a2e67c --- /dev/null +++ b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/chat/ChatStore.kt @@ -0,0 +1,202 @@ +package com.algolia.instantsearch.agent.chat + +import com.algolia.instantsearch.agent.ExperimentalAgentStudioApi +import com.algolia.instantsearch.agent.model.AgentStudioException +import com.algolia.instantsearch.agent.model.ChatStatus +import com.algolia.instantsearch.agent.model.MessageRole +import com.algolia.instantsearch.agent.model.PartState +import com.algolia.instantsearch.agent.model.UIMessage +import com.algolia.instantsearch.agent.model.UIMessageChunk +import com.algolia.instantsearch.agent.model.UIMessagePart +import com.algolia.instantsearch.agent.transport.AgentStudioRequest +import com.algolia.instantsearch.agent.transport.AgentStudioTransport +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlin.random.Random + +/** + * Native counterpart of the JS `Chat` class + * (`instantsearch.js/src/lib/chat/chat.ts`). + * + * Aggregates streamed [UIMessageChunk]s into a list of [UIMessage]s and + * exposes [messages], [status], and [error] as [StateFlow]s so Compose / View + * code can observe them. + * + * @param scope coroutine scope owning streaming jobs. Pass `viewModelScope` in + * Android, or a custom scope tied to the lifecycle of your screen. + * @param conversationId pass a stable id (prefixed `alg_cnv_` per Algolia + * conventions) to enable server-side conversation persistence; pass `null` + * to let the store generate one. + * + * This is **experimental** API — see [ExperimentalAgentStudioApi]. + */ +@ExperimentalAgentStudioApi +public class ChatStore( + private val transport: AgentStudioTransport, + private val scope: CoroutineScope, + conversationId: String? = null, + initialMessages: List = emptyList(), + private val idGenerator: () -> String = { "alg_msg_" + randomId() }, +) { + public val conversationId: String = conversationId ?: ("alg_cnv_" + randomId()) + + private val _messages = MutableStateFlow(initialMessages) + public val messages: StateFlow> = _messages.asStateFlow() + + private val _status = MutableStateFlow(ChatStatus.Ready) + public val status: StateFlow = _status.asStateFlow() + + private val _error = MutableStateFlow(null) + public val error: StateFlow = _error.asStateFlow() + + /** + * Prompt suggestions ("what to ask next") streamed by the agent as a + * `data-suggestions` part. Derived from the last assistant message, matching + * the web `connectChat` behavior. Hosts typically show these as tappable + * chips while [status] is [ChatStatus.Ready]. + */ + public val suggestions: StateFlow> = messages + .map { extractSuggestions(it) } + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + private var streamingJob: Job? = null + private var assistantIndex: Int? = null + + /** + * Send a user text message and start streaming the assistant response. + * Cancels any in-flight request. + */ + public fun send(text: String): Job { + val userMessage = UIMessage( + id = idGenerator(), + role = MessageRole.User, + parts = listOf(UIMessagePart.Text(id = null, text = text, state = PartState.Done)), + ) + _messages.update { it + userMessage } + return startStream(AgentStudioRequest.Trigger.SubmitMessage) + } + + /** Re-issue the last user message, replacing the trailing assistant message (if any). */ + public fun regenerate(): Job { + _messages.update { current -> + if (current.lastOrNull()?.role == MessageRole.Assistant) current.dropLast(1) else current + } + return startStream(AgentStudioRequest.Trigger.RegenerateMessage) + } + + /** Cancel any in-flight streaming request. Status returns to [ChatStatus.Ready]. */ + public fun stop() { + streamingJob?.cancel() + streamingJob = null + assistantIndex = null + if (_status.value != ChatStatus.Error) { + _status.value = ChatStatus.Ready + } + } + + /** Wipe local conversation state. Does not call any server endpoint. */ + public fun clear() { + stop() + _messages.value = emptyList() + _error.value = null + _status.value = ChatStatus.Ready + } + + public fun clearError() { + _error.value = null + if (_status.value == ChatStatus.Error) _status.value = ChatStatus.Ready + } + + private fun startStream(trigger: AgentStudioRequest.Trigger): Job { + streamingJob?.cancel() + _error.value = null + _status.value = ChatStatus.Submitted + assistantIndex = null + + val wireMessages = _messages.value.mapNotNull { msg -> + val text = msg.plainText + if (text.isEmpty()) null + else AgentStudioRequest.WireMessage(id = msg.id, role = msg.role, text = text) + } + + val request = AgentStudioRequest( + conversationId = conversationId, + messages = wireMessages, + trigger = trigger, + ) + + return scope.launch { + try { + transport.sendMessages(request).collect { chunk -> handleChunk(chunk) } + _status.value = ChatStatus.Ready + } catch (cancellation: kotlinx.coroutines.CancellationException) { + throw cancellation + } catch (throwable: Throwable) { + _status.value = ChatStatus.Error + _error.value = (throwable as? AgentStudioException) + ?: AgentStudioException.Underlying( + message = throwable.message ?: throwable::class.simpleName.orEmpty(), + cause = throwable, + ) + } + }.also { streamingJob = it } + } + + private fun handleChunk(chunk: UIMessageChunk) { + when (chunk) { + is UIMessageChunk.Start -> { + val id = chunk.messageId ?: idGenerator() + _messages.update { it + UIMessage(id = id, role = MessageRole.Assistant) } + assistantIndex = _messages.value.lastIndex + _status.value = ChatStatus.Streaming + } + is UIMessageChunk.Error -> { + _status.value = ChatStatus.Error + _error.value = AgentStudioException.Underlying(chunk.errorText) + } + else -> { + ensureAssistantMessage() + val index = assistantIndex ?: return + _messages.update { current -> + current.toMutableList().apply { set(index, ChunkReducer.apply(chunk, current[index])) } + } + if (_status.value == ChatStatus.Submitted) _status.value = ChatStatus.Streaming + } + } + } + + private fun ensureAssistantMessage() { + if (assistantIndex == null) { + _messages.update { it + UIMessage(id = idGenerator(), role = MessageRole.Assistant) } + assistantIndex = _messages.value.lastIndex + } + } + + public companion object { + private fun randomId(): String { + val bytes = ByteArray(16).also { Random.nextBytes(it) } + return bytes.joinToString("") { (it.toInt() and 0xff).toString(16).padStart(2, '0') } + } + + private fun extractSuggestions(messages: List): List { + val message = messages.lastOrNull { it.role == MessageRole.Assistant } ?: return emptyList() + val part = message.parts + .filterIsInstance() + .firstOrNull { it.name == "suggestions" } ?: return emptyList() + val array = (part.json as? JsonObject)?.get("suggestions") as? JsonArray ?: return emptyList() + return array.mapNotNull { it.jsonPrimitive.contentOrNull } + } + } +} diff --git a/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/chat/ChunkReducer.kt b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/chat/ChunkReducer.kt new file mode 100644 index 000000000..65cd5590d --- /dev/null +++ b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/chat/ChunkReducer.kt @@ -0,0 +1,181 @@ +package com.algolia.instantsearch.agent.chat + +import com.algolia.instantsearch.agent.model.PartState +import com.algolia.instantsearch.agent.model.ToolCallState +import com.algolia.instantsearch.agent.model.ToolUIPart +import com.algolia.instantsearch.agent.model.UIMessage +import com.algolia.instantsearch.agent.model.UIMessageChunk +import com.algolia.instantsearch.agent.model.UIMessagePart +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull + +/** + * Reduces a [UIMessageChunk] onto an existing assistant [UIMessage]. + * + * Native equivalent of the chunk-handling switch inside `AbstractChat.processStreamChunk` + * in `instantsearch.js/src/lib/ai-lite/abstract-chat.ts`. + */ +internal object ChunkReducer { + + private val json = Json { ignoreUnknownKeys = true } + + /** + * Parse accumulated `data-tool-output-delta` text into a preliminary + * [ToolCallState.OutputAvailable]. While the JSON is still incomplete we keep + * the part in [ToolCallState.InputAvailable] so the UI shows a running state. + */ + private fun toolStateFromRawOutput(raw: String, previousInput: JsonElement): ToolCallState { + val parsed: JsonElement? = runCatching { json.parseToJsonElement(raw) }.getOrNull() + return if (parsed != null) { + ToolCallState.OutputAvailable(input = previousInput, output = parsed, preliminary = true) + } else { + ToolCallState.InputAvailable(input = previousInput) + } + } + + fun apply(chunk: UIMessageChunk, message: UIMessage): UIMessage { + return when (chunk) { + is UIMessageChunk.Start, is UIMessageChunk.Error -> message + UIMessageChunk.StartStep -> message.copy(parts = message.parts + UIMessagePart.StepStart) + UIMessageChunk.FinishStep, UIMessageChunk.Finish, UIMessageChunk.Abort -> { + message.copy(parts = message.parts.map { part -> + when (part) { + is UIMessagePart.Text -> if (part.state == PartState.Streaming) part.copy(state = PartState.Done) else part + is UIMessagePart.Reasoning -> if (part.state == PartState.Streaming) part.copy(state = PartState.Done) else part + else -> part + } + }) + } + + is UIMessageChunk.TextStart -> message.copy( + parts = message.parts + UIMessagePart.Text(id = chunk.id, text = "", state = PartState.Streaming), + ) + is UIMessageChunk.TextDelta -> appendToLastText(message, chunk.id, chunk.delta) + is UIMessageChunk.TextEnd -> updateLastText(message, chunk.id) { it.copy(state = PartState.Done) } + + is UIMessageChunk.ReasoningStart -> message.copy( + parts = message.parts + UIMessagePart.Reasoning(id = chunk.id, text = "", state = PartState.Streaming), + ) + is UIMessageChunk.ReasoningDelta -> appendToLastReasoning(message, chunk.id, chunk.delta) + is UIMessageChunk.ReasoningEnd -> updateLastReasoning(message, chunk.id) { it.copy(state = PartState.Done) } + + is UIMessageChunk.ToolInputStart -> upsertTool(message, chunk.toolCallId, + insert = { ToolUIPart(chunk.toolName, chunk.toolCallId, ToolCallState.InputStreaming(null)) }, + update = { it.copy(state = ToolCallState.InputStreaming(null)) }, + ) + is UIMessageChunk.ToolInputDelta -> message + is UIMessageChunk.ToolInputAvailable -> upsertTool(message, chunk.toolCallId, + insert = { ToolUIPart(chunk.toolName, chunk.toolCallId, ToolCallState.InputAvailable(chunk.input)) }, + update = { it.copy(state = ToolCallState.InputAvailable(chunk.input)) }, + ) + is UIMessageChunk.ToolOutputAvailable -> upsertTool(message, chunk.toolCallId, + insert = { + ToolUIPart( + chunk.toolName, chunk.toolCallId, + ToolCallState.OutputAvailable(input = JsonNull, output = chunk.output, preliminary = chunk.preliminary), + ) + }, + update = { existing -> + val previousInput = (existing.state as? ToolCallState.InputAvailable)?.input + ?: (existing.state as? ToolCallState.OutputAvailable)?.input + ?: JsonNull + existing.copy(state = ToolCallState.OutputAvailable(previousInput, chunk.output, chunk.preliminary)) + }, + ) + is UIMessageChunk.ToolError -> upsertTool(message, chunk.toolCallId, + insert = { + ToolUIPart(chunk.toolName, chunk.toolCallId, ToolCallState.OutputError(chunk.input, chunk.errorText)) + }, + update = { it.copy(state = ToolCallState.OutputError(chunk.input, chunk.errorText)) }, + ) + is UIMessageChunk.ToolOutputDelta -> upsertTool(message, chunk.toolCallId, + insert = { + val raw = chunk.delta + ToolUIPart( + toolName = chunk.toolName ?: "", + toolCallId = chunk.toolCallId, + state = toolStateFromRawOutput(raw, previousInput = JsonNull), + rawOutput = raw, + ) + }, + update = { existing -> + val raw = existing.rawOutput + chunk.delta + val previousInput = (existing.state as? ToolCallState.InputAvailable)?.input + ?: (existing.state as? ToolCallState.OutputAvailable)?.input + ?: JsonNull + existing.copy(state = toolStateFromRawOutput(raw, previousInput), rawOutput = raw) + }, + ) + + is UIMessageChunk.SourceUrl -> message.copy( + parts = message.parts + UIMessagePart.SourceUrl(chunk.sourceId, chunk.url, chunk.title), + ) + is UIMessageChunk.File -> message.copy( + parts = message.parts + UIMessagePart.File(chunk.mediaType, chunk.url, filename = null), + ) + is UIMessageChunk.Data -> message.copy( + parts = message.parts + UIMessagePart.Data(chunk.name, chunk.id, chunk.json), + ) + is UIMessageChunk.Unknown -> message.copy( + parts = message.parts + UIMessagePart.Unknown(chunk.typeIdentifier, chunk.json), + ) + } + } + + private fun appendToLastText(message: UIMessage, id: String, delta: String): UIMessage { + val index = message.parts.indexOfLast { it is UIMessagePart.Text && it.id == id } + return if (index >= 0) { + val current = message.parts[index] as UIMessagePart.Text + val updated = current.copy(text = current.text + delta, state = PartState.Streaming) + message.copy(parts = message.parts.toMutableList().apply { set(index, updated) }) + } else { + message.copy(parts = message.parts + UIMessagePart.Text(id, delta, PartState.Streaming)) + } + } + + private fun updateLastText(message: UIMessage, id: String, transform: (UIMessagePart.Text) -> UIMessagePart.Text): UIMessage { + val index = message.parts.indexOfLast { it is UIMessagePart.Text && it.id == id } + if (index < 0) return message + val updated = transform(message.parts[index] as UIMessagePart.Text) + return message.copy(parts = message.parts.toMutableList().apply { set(index, updated) }) + } + + private fun appendToLastReasoning(message: UIMessage, id: String, delta: String): UIMessage { + val index = message.parts.indexOfLast { it is UIMessagePart.Reasoning && it.id == id } + return if (index >= 0) { + val current = message.parts[index] as UIMessagePart.Reasoning + val updated = current.copy(text = current.text + delta, state = PartState.Streaming) + message.copy(parts = message.parts.toMutableList().apply { set(index, updated) }) + } else { + message.copy(parts = message.parts + UIMessagePart.Reasoning(id, delta, PartState.Streaming)) + } + } + + private fun updateLastReasoning( + message: UIMessage, + id: String, + transform: (UIMessagePart.Reasoning) -> UIMessagePart.Reasoning, + ): UIMessage { + val index = message.parts.indexOfLast { it is UIMessagePart.Reasoning && it.id == id } + if (index < 0) return message + val updated = transform(message.parts[index] as UIMessagePart.Reasoning) + return message.copy(parts = message.parts.toMutableList().apply { set(index, updated) }) + } + + private fun upsertTool( + message: UIMessage, + toolCallId: String, + insert: () -> ToolUIPart, + update: (ToolUIPart) -> ToolUIPart, + ): UIMessage { + val index = message.parts.indexOfFirst { it is UIMessagePart.Tool && it.part.toolCallId == toolCallId } + return if (index >= 0) { + val current = (message.parts[index] as UIMessagePart.Tool).part + val updatedPart = UIMessagePart.Tool(update(current)) + message.copy(parts = message.parts.toMutableList().apply { set(index, updatedPart) }) + } else { + message.copy(parts = message.parts + UIMessagePart.Tool(insert())) + } + } +} diff --git a/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/model/ChatStatus.kt b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/model/ChatStatus.kt new file mode 100644 index 000000000..7bb206a27 --- /dev/null +++ b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/model/ChatStatus.kt @@ -0,0 +1,26 @@ +package com.algolia.instantsearch.agent.model + +import com.algolia.instantsearch.agent.ExperimentalAgentStudioApi + +/** + * State of the chat lifecycle, mirroring the JS `ChatStatus` enum from + * `instantsearch.js/src/lib/ai-lite/types.ts`. + * + * This is **experimental** API — see [ExperimentalAgentStudioApi]. + */ +@ExperimentalAgentStudioApi +public enum class ChatStatus { Submitted, Streaming, Ready, Error } + +/** + * Errors surfaced by the agent studio client. + * + * This is **experimental** API — see [ExperimentalAgentStudioApi]. + */ +@ExperimentalAgentStudioApi +public sealed class AgentStudioException(message: String, cause: Throwable? = null) : Exception(message, cause) { + public class Http(public val status: Int, public val body: String?) : + AgentStudioException("HTTP $status${body?.let { " — $it" } ?: ""}") + public class MalformedChunk(payload: String) : AgentStudioException("Malformed chunk: $payload") + public class StreamClosed : AgentStudioException("Stream closed unexpectedly") + public class Underlying(message: String, cause: Throwable? = null) : AgentStudioException(message, cause) +} diff --git a/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/model/UIMessage.kt b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/model/UIMessage.kt new file mode 100644 index 000000000..cd419d53d --- /dev/null +++ b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/model/UIMessage.kt @@ -0,0 +1,127 @@ +package com.algolia.instantsearch.agent.model + +import com.algolia.instantsearch.agent.ExperimentalAgentStudioApi +import kotlinx.serialization.json.JsonElement + +/** + * Role of a message in an Agent Studio conversation. + * + * Mirrors the JS `UIMessage['role']` from + * `instantsearch.js/src/lib/ai-lite/types.ts`. + * + * This is **experimental** API — see [ExperimentalAgentStudioApi]. + */ +@ExperimentalAgentStudioApi +public enum class MessageRole { + System, User, Assistant; + + public val wireValue: String + get() = when (this) { + System -> "system" + User -> "user" + Assistant -> "assistant" + } + + public companion object { + public fun fromWire(value: String): MessageRole = when (value) { + "system" -> System + "user" -> User + "assistant" -> Assistant + else -> error("Unknown role: $value") + } + } +} + +/** + * State of a streaming part (text, reasoning, …) inside a message. + * + * This is **experimental** API — see [ExperimentalAgentStudioApi]. + */ +@ExperimentalAgentStudioApi +public enum class PartState { Streaming, Done } + +/** + * A single part of an assembled [UIMessage]. + * + * Mirrors the JS `UIMessagePart` discriminated union; modeled here as a sealed + * hierarchy with an [Unknown] fallback so newly-introduced variants don't + * crash old clients. + * + * This is **experimental** API — see [ExperimentalAgentStudioApi]. + */ +@ExperimentalAgentStudioApi +public sealed interface UIMessagePart { + public data class Text(val id: String?, val text: String, val state: PartState?) : UIMessagePart + public data class Reasoning(val id: String?, val text: String, val state: PartState?) : UIMessagePart + public data class SourceUrl(val sourceId: String, val url: String, val title: String?) : UIMessagePart + public data class File(val mediaType: String, val url: String, val filename: String?) : UIMessagePart + public object StepStart : UIMessagePart + + /** A tool invocation, keyed by [Tool.toolCallId]. */ + public data class Tool(val part: ToolUIPart) : UIMessagePart + + /** Custom `data-` part. Payload kept as raw JSON. */ + public data class Data(val name: String, val id: String?, val json: JsonElement) : UIMessagePart + + /** Forward-compat fallback for unknown chunk types. */ + public data class Unknown(val typeIdentifier: String, val json: JsonElement) : UIMessagePart +} + +/** + * Lifecycle of a tool call. Mirrors the AI SDK 5 `tool-input-*` / + * `tool-output-*` chunk progression. + * + * This is **experimental** API — see [ExperimentalAgentStudioApi]. + */ +@ExperimentalAgentStudioApi +public sealed interface ToolCallState { + public data class InputStreaming(val partial: JsonElement?) : ToolCallState + public data class InputAvailable(val input: JsonElement) : ToolCallState + public data class OutputAvailable( + val input: JsonElement, + val output: JsonElement, + val preliminary: Boolean, + ) : ToolCallState + public data class OutputError(val input: JsonElement?, val errorText: String) : ToolCallState +} + +/** + * A single tool invocation embedded in an assistant message. + * + * This is **experimental** API — see [ExperimentalAgentStudioApi]. + */ +@ExperimentalAgentStudioApi +public data class ToolUIPart( + val toolName: String, + val toolCallId: String, + val state: ToolCallState, + /** + * Raw, possibly-incomplete tool output accumulated from + * `data-tool-output-delta` chunks before the final `tool-output-available` + * arrives. Internal bookkeeping; the parsed result lives in [state]. + */ + val rawOutput: String = "", +) + +/** + * An assembled message ready to be rendered by the host app. + * + * Metadata is intentionally typeless in v0.1; callers that need to decode it + * can do so from the original `start` chunk. We keep it a `JsonElement?` to + * stay generic without forcing a type parameter through the whole API. + * + * This is **experimental** API — see [ExperimentalAgentStudioApi]. + */ +@ExperimentalAgentStudioApi +public data class UIMessage( + val id: String, + val role: MessageRole, + val parts: List = emptyList(), + val metadata: JsonElement? = null, +) { + /** Concatenated text from every [UIMessagePart.Text] part. */ + val plainText: String + get() = parts.asSequence() + .filterIsInstance() + .joinToString(separator = "") { it.text } +} diff --git a/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/model/UIMessageChunk.kt b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/model/UIMessageChunk.kt new file mode 100644 index 000000000..677f62ea2 --- /dev/null +++ b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/model/UIMessageChunk.kt @@ -0,0 +1,176 @@ +package com.algolia.instantsearch.agent.model + +import com.algolia.instantsearch.agent.ExperimentalAgentStudioApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * One frame parsed from the SSE stream returned by the Agent Studio + * completions endpoint. + * + * Mirrors `UIMessageChunk` from `instantsearch.js/src/lib/ai-lite/types.ts`. + * The closed set of variants below covers what the v0.1 [com.algolia.instantsearch.agent.chat.ChatStore] + * aggregates today; anything else falls into [Unknown] so backend additions + * don't break parsing. + * + * This is **experimental** API — see [ExperimentalAgentStudioApi]. + */ +@ExperimentalAgentStudioApi +public sealed interface UIMessageChunk { + public data class Start(val messageId: String?) : UIMessageChunk + public object StartStep : UIMessageChunk + public object FinishStep : UIMessageChunk + public object Finish : UIMessageChunk + public object Abort : UIMessageChunk + + public data class TextStart(val id: String) : UIMessageChunk + public data class TextDelta(val id: String, val delta: String) : UIMessageChunk + public data class TextEnd(val id: String) : UIMessageChunk + + public data class ReasoningStart(val id: String) : UIMessageChunk + public data class ReasoningDelta(val id: String, val delta: String) : UIMessageChunk + public data class ReasoningEnd(val id: String) : UIMessageChunk + + public data class ToolInputStart(val toolName: String, val toolCallId: String) : UIMessageChunk + public data class ToolInputDelta(val toolName: String, val toolCallId: String, val inputTextDelta: String) : UIMessageChunk + public data class ToolInputAvailable(val toolName: String, val toolCallId: String, val input: JsonElement) : UIMessageChunk + public data class ToolOutputAvailable( + val toolName: String, + val toolCallId: String, + val output: JsonElement, + val preliminary: Boolean, + ) : UIMessageChunk + + public data class ToolError( + val toolName: String, + val toolCallId: String, + val errorText: String, + val input: JsonElement?, + ) : UIMessageChunk + + /** + * Incremental tool output, streamed as `data-tool-output-delta`. The agent + * sends the tool's JSON output in string fragments that must be concatenated + * and parsed once complete. Used by `algolia_display_results` and, for some + * agents, the search tool. Mirrors the same chunk in `ai-lite/types.ts`. + */ + public data class ToolOutputDelta(val toolCallId: String, val toolName: String?, val delta: String) : UIMessageChunk + + public data class SourceUrl(val sourceId: String, val url: String, val title: String?) : UIMessageChunk + public data class File(val url: String, val mediaType: String) : UIMessageChunk + + public data class Data(val name: String, val id: String?, val json: JsonElement) : UIMessageChunk + + public data class Error(val errorText: String) : UIMessageChunk + + public data class Unknown(val typeIdentifier: String, val json: JsonElement) : UIMessageChunk + + public companion object { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + /** + * Decode a single SSE payload (the JSON after `data:`) into a chunk. + * Returns `null` if the payload is malformed (callers ignore those, + * matching the JS [parseJsonEventStream] behavior). + */ + public fun decode(payload: String): UIMessageChunk? { + return runCatching { decodeOrThrow(payload) }.getOrNull() + } + + public fun decodeOrThrow(payload: String): UIMessageChunk { + val element = json.parseToJsonElement(payload) + val obj = element.jsonObject + val type = obj["type"]?.jsonPrimitive?.contentOrNull + ?: error("Chunk missing `type`") + + fun str(key: String): String? = obj[key]?.jsonPrimitive?.contentOrNull + fun bool(key: String): Boolean? = obj[key]?.jsonPrimitive?.booleanOrNull + fun sub(key: String): JsonElement? = obj[key] + + return when (type) { + "start" -> Start(str("messageId")) + "start-step" -> StartStep + "finish-step" -> FinishStep + "finish" -> Finish + "abort" -> Abort + + "text-start" -> TextStart(requireNotNull(str("id"))) + "text-delta" -> TextDelta(requireNotNull(str("id")), requireNotNull(str("delta"))) + "text-end" -> TextEnd(requireNotNull(str("id"))) + + "reasoning-start" -> ReasoningStart(requireNotNull(str("id"))) + "reasoning-delta" -> ReasoningDelta(requireNotNull(str("id")), requireNotNull(str("delta"))) + "reasoning-end" -> ReasoningEnd(requireNotNull(str("id"))) + + "tool-input-start" -> ToolInputStart( + toolName = requireNotNull(str("toolName")), + toolCallId = requireNotNull(str("toolCallId")), + ) + // `tool-input-delta`, `tool-output-available` and `tool-error` reference an + // existing tool call by `toolCallId`; `toolName` is optional on the wire (the AI + // SDK only needs it on `tool-input-start`/`tool-input-available`). Don't require it + // here, otherwise these chunks get silently dropped and the products never render. + "tool-input-delta" -> ToolInputDelta( + toolName = str("toolName") ?: "", + toolCallId = requireNotNull(str("toolCallId")), + inputTextDelta = requireNotNull(str("inputTextDelta")), + ) + "tool-input-available" -> ToolInputAvailable( + toolName = requireNotNull(str("toolName")), + toolCallId = requireNotNull(str("toolCallId")), + input = requireNotNull(sub("input")), + ) + "tool-output-available" -> ToolOutputAvailable( + toolName = str("toolName") ?: "", + toolCallId = requireNotNull(str("toolCallId")), + output = requireNotNull(sub("output")), + preliminary = bool("preliminary") ?: false, + ) + "tool-error" -> ToolError( + toolName = str("toolName") ?: "", + toolCallId = requireNotNull(str("toolCallId")), + errorText = requireNotNull(str("errorText")), + input = sub("input"), + ) + + "source-url" -> SourceUrl( + sourceId = requireNotNull(str("sourceId")), + url = requireNotNull(str("url")), + title = str("title"), + ) + "file" -> File( + url = requireNotNull(str("url")), + mediaType = requireNotNull(str("mediaType")), + ) + "error" -> Error(str("errorText") ?: "Unknown error") + "data-tool-output-delta" -> { + val data = sub("data")?.jsonObject + ToolOutputDelta( + toolCallId = requireNotNull(data?.get("toolCallId")?.jsonPrimitive?.contentOrNull), + toolName = data?.get("toolName")?.jsonPrimitive?.contentOrNull, + delta = data?.get("delta")?.jsonPrimitive?.contentOrNull ?: "", + ) + } + else -> { + if (type.startsWith("data-")) { + Data( + name = type.removePrefix("data-"), + id = str("id"), + json = sub("data") ?: JsonObject(emptyMap()), + ) + } else { + Unknown(type, element) + } + } + } + } + } +} diff --git a/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/transport/AgentStudioEndpoint.kt b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/transport/AgentStudioEndpoint.kt new file mode 100644 index 000000000..ef1db75fa --- /dev/null +++ b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/transport/AgentStudioEndpoint.kt @@ -0,0 +1,38 @@ +package com.algolia.instantsearch.agent.transport + +import com.algolia.instantsearch.agent.ExperimentalAgentStudioApi + +/** + * Builds the Agent Studio completions URL for a given Algolia application + * and agent. Mirrors the URL shape used by `connectChat` in + * `instantsearch.js/src/connectors/chat/connectChat.ts`: + * + * https://{appId}.algolia.net/agent-studio/1/agents/{agentId}/completions + * + * We always request `compatibilityMode=ai-sdk-5`. Streaming is opt-in through + * `stream=true` (the default for [com.algolia.instantsearch.agent.chat.ChatStore]). + * + * This is **experimental** API — see [ExperimentalAgentStudioApi]. + */ +@ExperimentalAgentStudioApi +public data class AgentStudioEndpoint( + public val appId: String, + public val agentId: String, + public val host: String = "$appId.algolia.net", +) { + + /** + * @param stream when `true`, the server streams chunks as SSE. + * @param cache when `false`, bypasses the server-side cache (used by + * `regenerate-message`). + */ + public fun completionsUrl(stream: Boolean = true, cache: Boolean = true): String { + val query = buildList { + add("compatibilityMode" to "ai-sdk-5") + add("stream" to if (stream) "true" else "false") + if (!cache) add("cache" to "false") + }.joinToString("&") { "${it.first}=${it.second}" } + + return "https://$host/agent-studio/1/agents/$agentId/completions?$query" + } +} diff --git a/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/transport/AgentStudioTransport.kt b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/transport/AgentStudioTransport.kt new file mode 100644 index 000000000..7fcec9ec7 --- /dev/null +++ b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/transport/AgentStudioTransport.kt @@ -0,0 +1,147 @@ +package com.algolia.instantsearch.agent.transport + +import com.algolia.instantsearch.agent.ExperimentalAgentStudioApi +import com.algolia.instantsearch.agent.model.AgentStudioException +import com.algolia.instantsearch.agent.model.MessageRole +import com.algolia.instantsearch.agent.model.UIMessageChunk +import io.ktor.client.HttpClient +import io.ktor.client.request.headers +import io.ktor.client.request.preparePost +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpStatement +import io.ktor.client.statement.bodyAsChannel +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** + * What we send in the request body. + * + * This is **experimental** API — see [ExperimentalAgentStudioApi]. + */ +@ExperimentalAgentStudioApi +public data class AgentStudioRequest( + public val conversationId: String?, + public val messages: List, + public val trigger: Trigger = Trigger.SubmitMessage, + /** Extra top-level body fields (e.g. `algolia.searchParameters`). */ + public val extra: Map = emptyMap(), +) { + public enum class Trigger(public val wireValue: String) { + SubmitMessage("submit-message"), + RegenerateMessage("regenerate-message"); + } + + /** + * On-the-wire shape of a message. We keep it separate from the assembled + * [com.algolia.instantsearch.agent.model.UIMessage] because the wire + * format only carries text parts on the user side. + */ + public data class WireMessage( + public val id: String, + public val role: MessageRole, + public val text: String, + ) +} + +/** + * HTTP transport for the Agent Studio completions endpoint. + * + * Mirrors [DefaultChatTransport] from + * `instantsearch.js/src/lib/ai-lite/transport.ts`. + * + * @param httpClient supply your own (e.g. with a configured [HttpClient] + * engine, logging, or auth plugin) or use the default OkHttp engine. + * + * This is **experimental** API — see [ExperimentalAgentStudioApi]. + */ +@ExperimentalAgentStudioApi +public class AgentStudioTransport( + public val endpoint: AgentStudioEndpoint, + private val apiKey: String, + private val userAgent: String? = null, + private val httpClient: HttpClient = HttpClient(), +) { + + public fun sendMessages(request: AgentStudioRequest): Flow = flow { + val url = endpoint.completionsUrl( + stream = true, + cache = request.trigger != AgentStudioRequest.Trigger.RegenerateMessage, + ) + val body = encodeBody(request) + + val statement: HttpStatement = httpClient.preparePost(url) { + contentType(ContentType.Application.Json) + headers { + append(HttpHeaders.Accept, "text/event-stream") + append("x-algolia-application-id", endpoint.appId) + append("x-algolia-api-key", apiKey) + userAgent?.let { append("x-algolia-agent", it) } + } + setBody(body) + } + + statement.execute { response -> + if (response.status != HttpStatusCode.OK) { + throw AgentStudioException.Http(response.status.value, body = null) + } + SseEventStream.fromChannel(response.bodyAsChannel()).collect { emit(it) } + } + } + + private fun encodeBody(request: AgentStudioRequest): String { + val obj = buildJsonObject { + request.conversationId?.let { put("id", it) } + put("trigger", request.trigger.wireValue) + put("messages", buildJsonArray { + request.messages.forEach { msg -> + add(buildJsonObject { + put("id", msg.id) + put("role", msg.role.wireValue) + put("parts", buildJsonArray { + add(buildJsonObject { + put("type", "text") + put("text", msg.text) + }) + }) + }) + } + }) + request.extra.forEach { (key, value) -> put(key, value) } + } + return Json.encodeToString(JsonObject.serializer(), obj) + } + + public companion object { + /** + * Convenience factory that builds a transport from raw Algolia + * credentials. Pass values from your existing `ClientSearch` + * (`applicationId.raw`, `apiKey.raw`). + * + * Important: use a **search-only** API key. Never embed admin keys + * in a shipping mobile app. + */ + public fun fromCredentials( + appId: String, + apiKey: String, + agentId: String, + userAgent: String? = null, + httpClient: HttpClient = HttpClient(), + ): AgentStudioTransport = AgentStudioTransport( + endpoint = AgentStudioEndpoint(appId = appId, agentId = agentId), + apiKey = apiKey, + userAgent = userAgent, + httpClient = httpClient, + ) + } +} diff --git a/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/transport/SseEventStream.kt b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/transport/SseEventStream.kt new file mode 100644 index 000000000..9de885f5a --- /dev/null +++ b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/transport/SseEventStream.kt @@ -0,0 +1,48 @@ +package com.algolia.instantsearch.agent.transport + +import com.algolia.instantsearch.agent.ExperimentalAgentStudioApi +import com.algolia.instantsearch.agent.model.UIMessageChunk +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.readUTF8Line +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** + * Convert a streaming HTTP response body into a [Flow] of [UIMessageChunk]s. + * + * The wire format is the AI SDK 5 SSE protocol: + * + * data: // a UIMessageChunk + * data: [DONE] // end-of-stream sentinel + * // SSE keep-alive, ignored + * event: ... // ignored + * id: ... // ignored + * + * Mirrors [parseJsonEventStream] from + * `instantsearch.js/src/lib/ai-lite/stream-parser.ts`. + * + * This is **experimental** API — see [ExperimentalAgentStudioApi]. + */ +@ExperimentalAgentStudioApi +public object SseEventStream { + + public fun fromChannel(channel: ByteReadChannel): Flow = flow { + while (true) { + val line = channel.readUTF8Line() ?: break + val trimmed = line.trim() + if (trimmed.isEmpty()) continue + + val payload = extractJsonPayload(trimmed) ?: continue + if (payload == "[DONE]") break + + val chunk = UIMessageChunk.decode(payload) ?: continue + emit(chunk) + } + } + + internal fun extractJsonPayload(line: String): String? = when { + line.startsWith("data:") -> line.removePrefix("data:").trim() + line.startsWith("{") -> line + else -> null + } +} diff --git a/instantsearch-agent/src/commonTest/kotlin/com/algolia/instantsearch/agent/ChunkReducerTest.kt b/instantsearch-agent/src/commonTest/kotlin/com/algolia/instantsearch/agent/ChunkReducerTest.kt new file mode 100644 index 000000000..38d322520 --- /dev/null +++ b/instantsearch-agent/src/commonTest/kotlin/com/algolia/instantsearch/agent/ChunkReducerTest.kt @@ -0,0 +1,155 @@ +package com.algolia.instantsearch.agent + +import com.algolia.instantsearch.agent.chat.ChunkReducer +import com.algolia.instantsearch.agent.model.MessageRole +import com.algolia.instantsearch.agent.model.PartState +import com.algolia.instantsearch.agent.model.ToolCallState +import com.algolia.instantsearch.agent.model.UIMessage +import com.algolia.instantsearch.agent.model.UIMessageChunk +import com.algolia.instantsearch.agent.model.UIMessagePart +import com.algolia.instantsearch.agent.transport.AgentStudioEndpoint +import com.algolia.instantsearch.agent.transport.SseEventStream +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ChunkReducerTest { + @Test + fun textStreamProducesConcatenatedPart() { + var msg = UIMessage(id = "alg_msg_1", role = MessageRole.Assistant) + val chunks = listOf( + UIMessageChunk.StartStep, + UIMessageChunk.TextStart("t-1"), + UIMessageChunk.TextDelta("t-1", "Hello "), + UIMessageChunk.TextDelta("t-1", "world"), + UIMessageChunk.TextEnd("t-1"), + UIMessageChunk.FinishStep, + UIMessageChunk.Finish, + ) + for (chunk in chunks) { + msg = ChunkReducer.apply(chunk, msg) + } + assertEquals("Hello world", msg.plainText) + val text = msg.parts.filterIsInstance().first() + assertEquals(PartState.Done, text.state) + } + + @Test + fun toolLifecycleEndsWithOutputAvailable() { + var msg = UIMessage(id = "alg_msg_2", role = MessageRole.Assistant) + val input = buildJsonObject { put("query", JsonPrimitive("red shoes")) } + val output = buildJsonObject { put("hits", JsonNull) } + val chunks = listOf( + UIMessageChunk.ToolInputStart("algolia_search_index", "c-1"), + UIMessageChunk.ToolInputAvailable("algolia_search_index", "c-1", input), + UIMessageChunk.ToolOutputAvailable("algolia_search_index", "c-1", output, false), + ) + for (chunk in chunks) msg = ChunkReducer.apply(chunk, msg) + + val tool = msg.parts.filterIsInstance().first().part + val state = tool.state + assertTrue(state is ToolCallState.OutputAvailable, "expected OutputAvailable, got $state") + assertEquals(output, state.output) + } + + @Test + fun toolOutputAvailableWithoutToolNameIsNotDropped() { + // Agent Studio's ai-sdk-5 stream omits `toolName` on `tool-output-available` + // (the call is already identified by `toolCallId`). Decoding must not drop it. + val payload = """{"type":"tool-output-available","toolCallId":"c-1",""" + + """"output":{"hits":[{"objectID":"1","name":"Laptop"}]}}""" + val chunk = UIMessageChunk.decode(payload) + assertNotNull(chunk) + assertTrue(chunk is UIMessageChunk.ToolOutputAvailable) + assertEquals("c-1", chunk.toolCallId) + } + + @Test + fun toolOutputPreservesNameFromInputStartWhenOutputOmitsIt() { + var msg = UIMessage(id = "alg_msg_3", role = MessageRole.Assistant) + val output = buildJsonObject { + put( + "hits", + buildJsonArray { + add(buildJsonObject { put("objectID", JsonPrimitive("1")); put("name", JsonPrimitive("Laptop")) }) + }, + ) + } + val chunks = listOf( + UIMessageChunk.ToolInputStart("algolia_search_index", "c-1"), + // `toolName` absent on the wire -> decoded as empty string + UIMessageChunk.ToolOutputAvailable(toolName = "", toolCallId = "c-1", output = output, preliminary = false), + ) + for (chunk in chunks) msg = ChunkReducer.apply(chunk, msg) + + val tool = msg.parts.filterIsInstance().single().part + assertEquals("algolia_search_index", tool.toolName) + assertTrue(tool.state is ToolCallState.OutputAvailable) + } + + @Test + fun toolOutputDeltaAccumulatesAndParses() { + var msg = UIMessage(id = "alg_msg_4", role = MessageRole.Assistant) + val chunks = listOf( + UIMessageChunk.ToolInputStart("algolia_display_results", "c-9"), + UIMessageChunk.ToolOutputDelta("c-9", "algolia_display_results", "{\"intro\":\"curated\""), + UIMessageChunk.ToolOutputDelta("c-9", "algolia_display_results", ",\"groups\":[]}"), + ) + for (chunk in chunks) msg = ChunkReducer.apply(chunk, msg) + + val tool = msg.parts.filterIsInstance().single().part + val state = tool.state + assertTrue(state is ToolCallState.OutputAvailable, "expected OutputAvailable, got $state") + assertTrue(state.preliminary) + } + + @Test + fun dataSuggestionsChunkBecomesDataPart() { + // `data-suggestions` (prompt chips) is stored as a generic data part so + // the ChatStore can derive the suggestions list from it. + val payload = """{"type":"data-suggestions","data":{"suggestions":["How can I do X?","What about Y?"]}}""" + val chunk = UIMessageChunk.decode(payload) + assertNotNull(chunk) + assertTrue(chunk is UIMessageChunk.Data) + assertEquals("suggestions", chunk.name) + + var msg = UIMessage(id = "alg_msg_5", role = MessageRole.Assistant) + msg = ChunkReducer.apply(chunk, msg) + val part = msg.parts.filterIsInstance().single() + assertEquals("suggestions", part.name) + } + + @Test + fun sseExtractionIgnoresKeepalivesAndDoneSentinel() { + assertEquals("{\"type\":\"finish\"}", SseEventStream.extractJsonPayload("data: {\"type\":\"finish\"}")) + assertEquals("[DONE]", SseEventStream.extractJsonPayload("data: [DONE]")) + assertNull(SseEventStream.extractJsonPayload("event: ping")) + assertNull(SseEventStream.extractJsonPayload("id: 123")) + } + + @Test + fun endpointBuildsExpectedUrl() { + val endpoint = AgentStudioEndpoint(appId = "ABC123", agentId = "shopping-assistant") + assertEquals( + "https://ABC123.algolia.net/agent-studio/1/agents/shopping-assistant/completions" + + "?compatibilityMode=ai-sdk-5&stream=true", + endpoint.completionsUrl(stream = true, cache = true), + ) + } + + @Test + fun chunkDecodeHandlesUnknownTypeAsFallback() { + val chunk = UIMessageChunk.decode("""{"type":"future-feature","payload":42}""") + assertNotNull(chunk) + assertTrue(chunk is UIMessageChunk.Unknown) + assertEquals("future-feature", chunk.typeIdentifier) + } +} diff --git a/instantsearch-compose/src/main/java/com/algolia/instantsearch/compose/highlighting/HighlightedString.kt b/instantsearch-compose/src/main/java/com/algolia/instantsearch/compose/highlighting/HighlightedString.kt index 77c3d7fc2..0e645dcc3 100644 --- a/instantsearch-compose/src/main/java/com/algolia/instantsearch/compose/highlighting/HighlightedString.kt +++ b/instantsearch-compose/src/main/java/com/algolia/instantsearch/compose/highlighting/HighlightedString.kt @@ -11,6 +11,10 @@ import com.algolia.instantsearch.core.highlighting.HighlightedString * * @param spanStyle applied highlighting style */ +// `tokens` is a Kotlin `List`, so `forEach` resolves to the inlined +// `kotlin.collections.forEach` extension — not `java.lang.Iterable#forEach` +// (API 24). The NewApi flag here is a lint false positive. +@Suppress("NewApi") public fun HighlightedString.toAnnotatedString(spanStyle: SpanStyle = SpanStyle(fontWeight = FontWeight.Bold)): AnnotatedString { return with(AnnotatedString.Builder()) { tokens.forEach { (part, isHighlighted) -> diff --git a/settings.gradle.kts b/settings.gradle.kts index a625cdfce..02c16c015 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,6 +6,7 @@ include(":instantsearch-core") include(":instantsearch-insights") include(":instantsearch-compose") include(":instantsearch-utils") +include(":instantsearch-agent") // Extensions include(":extensions:android-paging3") include(":extensions:android-loading")