diff --git a/docs/STANDALONE.md b/docs/STANDALONE.md index abc802f..caaa137 100644 --- a/docs/STANDALONE.md +++ b/docs/STANDALONE.md @@ -1,6 +1,6 @@ # Standalone — рантайм агента agentik -`standalone` — это исполняемое JVM-приложение (точка входа `pw.binom.agentik.standalone.MainKt`), которое поднимает реальный агент `ChatAgent` (stateful, SQLite-персистентный) и навешивает на него HTTP+SSE фасад `:server`. LLM-движок выбирается через `AGENTIK_LLM_BACKEND` — на v1 поддерживаются `litert-openai` (любой OpenAI-совместимый endpoint) и `litert-google` (on-device движок LiteRT-LM 0.16.1 через нативную `.so`-библиотеку). +`standalone` — это исполняемое JVM-приложение (точка входа `pw.binom.agentik.standalone.MainKt`), которое поднимает реальный агент `ChatAgent` (stateful, SQLite-персистентный) и навешивает на него HTTP+SSE фасад `:server`. LLM-движок выбирается через `AGENTIK_LLM_BACKEND` — на v1 поддерживаются `litert-openai` (любой OpenAI-совместимый endpoint) и `litert-google` (on-device движок LiteRT-LM 0.17.0 через нативную `.so`-библиотеку из litertlm-jvm 0.17.0). Этот документ описывает, как `standalone` собран и как его расширять. @@ -209,7 +209,7 @@ suspend fun touch(id: String, now: Instant) `LlmConfig.fromEnv()` парсит env, валидирует обязательные поля и выбирает бэкенд через `AGENTIK_LLM_BACKEND`: - `openai` (default) — `litert-openai`, текст-онли чат против любого OpenAI-совместимого endpoint. -- `google` — `litert-google` (LiteRT-LM 0.16.1, on-device `.task`/`.litertlm` модель, требует нативной библиотеки через `litertlm-jvm`). +- `google` — `litert-google` (LiteRT-LM 0.17.0, on-device `.task`/`.litertlm` модель, требует нативной библиотеки через `litertlm-jvm`). ### Общие env @@ -238,15 +238,11 @@ suspend fun touch(id: String, now: Instant) `AGENTIK_DB_PATH=:memory:` создаёт in-memory БД (только для тестов и интеграционных проверок). -### Long-lived LiteConversation — ОБЯЗАТЕЛЬНО для google +### Long-lived LiteConversation — ОБЯЗАТЕЛЬНО для обоих бэкендов -`LiteConversation` от любого litert-бэкенда — это **долгоживущая stateful ручка**: она держит историю сообщений и (для google) KV-cache/sampler-state. На v1 `ChatConversation` создаёт `LiteConversation` один раз (на первом `send`) и переиспользует на всех последующих turn'ах той же беседы. Пересоздание LiteConversation на каждый send ломает KV-cache для google (и в v1 приводит к ошибке chat template «roles must alternate» после двух user-сообщений). +`LiteConversation` от любого litert-бэкенда — это **долгоживущая stateful ручка**: она держит историю сообщений и (для google) KV-cache/sampler-state. `ChatConversation` создаёт `LiteConversation` один раз (на первом `send`) и переиспользует на всех последующих turn'ах той же беседы. Пересоздание LiteConversation на каждый send ломает KV-cache для google (LiteRT-LM 0.17.0 умеет правильно восстанавливать state при `systemInstruction + пары User/Assistant` в `initialMessages`). -`AGENTIK_GOOGLE_FOLD_SYSTEM_INTO_FIRST_USER` (default `true` для google) — фолдит системный промпт в первое user-сообщение, чтобы движок увидел только `[User+system, Assistant, User, ...]` и не падал на alternation. Для openai этот режим отключён. - -### Workaround для litert-google-jvm 0.16.1 - -Баг в `sendStreamContents` — поток не закрывается после `isDone = true`. `ChatConversation` оборачивает стрим в `transformWhile { !delta.isDone }`, поэтому SSE получает корректный `End` event. +`LiteLlm.capabilities: LiteCapabilities?` (litert-api 7+) — `litert-google` читает из заголовка on-disk модели (text/vision/audio, supportsThinking, supportsFunctionCalling, maxVisionTokenBudget); `litert-openai` возвращает `null` (модель не хранится на диске). Используй для фильтрации модальностей в клиенте. --- diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 105dec5..a0ead7d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ kotlinx-datetime = "0.8.0" ktor = "3.1.3" agui = "0.1.0" a2a = "1.0.0-SNAPSHOT" -litert = "6" +litert = "7" sqldelight = "2.3.2" [plugins] diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt index 2908001..79168d4 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatAgent.kt @@ -71,7 +71,7 @@ class ChatAgent( ) } } - val conv = ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = llmConfig.systemPrompt, foldSystemIntoFirstUser = llmConfig.foldSystemIntoFirstUser) + val conv = ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = llmConfig.systemPrompt) runBlocking { liveLock.withLock { live[conv.id] = conv } } @@ -82,7 +82,7 @@ class ChatAgent( override suspend fun getConversation(id: String): ProtoConversation? { liveLock.withLock { live[id] }?.let { if (!it.isClosed) return it } val rec = stores.conversations.get(id) ?: return null - return ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = llmConfig.systemPrompt, foldSystemIntoFirstUser = llmConfig.foldSystemIntoFirstUser).also { + return ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = llmConfig.systemPrompt).also { liveLock.withLock { live[id] = it } } } diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt index 2632efd..bd13b3c 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -59,7 +58,6 @@ class ChatConversation( private val stores: SqliteStores, private val llm: LiteLlm, private val systemPrompt: String, - private val foldSystemIntoFirstUser: Boolean = false, ) : ProtoConversation, AutoCloseable { private var record: ConversationRecord = record @@ -184,12 +182,7 @@ class ChatConversation( val reply = StringBuilder() try { - // Некоторые бэкенды (litert-google-jvm 0.16.1) не закрывают стрим после `isDone = true`, - // поэтому заворачиваем в transformWhile: видим isDone → отдаём дельту и завершаем flow. - liteConv.sendStreamContents(parts).transformWhile { delta -> - emit(delta) - !delta.isDone - }.collect { delta -> + liteConv.sendStreamContents(parts).collect { delta -> if (delta.text.isNotEmpty()) { reply.append(delta.text) emitEvent(ProtoEvent.AppendText(date = now(), body = delta.text)) @@ -238,9 +231,6 @@ class ChatConversation( * уже записанное в audit + working memory, но ещё не отправленное в LLM — мы отдадим * его через [LiteConversation.sendStreamContents]). Это предотвращает дублирование * "user → user" в LiteConversation history. - * - * Если [foldSystemIntoFirstUser] — система не передаётся как `systemInstruction`, - * а фолдится в первое user-сообщение (нужно для Gemma-3 шаблона LiteRT-LM 0.16.1). */ private suspend fun getOrCreateLiteConversation(excludeUserSourceId: String? = null): LiteConversation { liteConv?.let { return it } @@ -268,21 +258,8 @@ class ChatConversation( } val config = LiteConversationConfig( - systemInstruction = if (foldSystemIntoFirstUser) null else resolvedSystemPrompt.takeIf { it.isNotBlank() }, - initialMessages = if (foldSystemIntoFirstUser) { - val firstUser = pastTurns.firstOrNull { it.role == LiteRole.USER } - if (firstUser != null && resolvedSystemPrompt.isNotBlank()) { - val folded = LiteMessage( - role = LiteRole.USER, - contents = listOf(LiteContentPart.Text(resolvedSystemPrompt + "\n\n")) + firstUser.contents, - ) - listOf(folded) + pastTurns.drop(1) - } else { - pastTurns - } - } else { - pastTurns - }, + systemInstruction = resolvedSystemPrompt.takeIf { it.isNotBlank() }, + initialMessages = pastTurns, ) return llm.createConversation(config).also { liteConv = it } diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/llm/LlmConfig.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/llm/LlmConfig.kt index 8b1ef3d..6f5fb32 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/llm/LlmConfig.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/llm/LlmConfig.kt @@ -12,7 +12,6 @@ data class LlmConfig( val systemPrompt: String, val openai: LitertOpenAiConfig? = null, val google: GoogleConfig? = null, - val foldSystemIntoFirstUser: Boolean = backend == LlmBackend.GOOGLE, ) { init { when (backend) { diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt index 1ee2916..b64ab38 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt @@ -326,6 +326,7 @@ class ChatAgentTest { private fun fakeLiteLlmForReload(): LiteLlm = object : LiteLlm { override val backendName: String = "fake" + override val capabilities: pw.binom.litert.LiteCapabilities? = null override fun isInitialized(): Boolean = true override fun createConversation(config: LiteConversationConfig): LiteConversation = error("not used in reload test") @@ -357,6 +358,7 @@ class ChatAgentTest { /** Поддельный LiteLlm: возвращает fakeLlm.reply в sendStreamContents, опционально запоминает history. */ private class FakeLiteLlm : LiteLlm { override val backendName: String = "fake" + override val capabilities: pw.binom.litert.LiteCapabilities? = null var reply: String = "" var rememberHistory: Boolean = false var slow: Boolean = false