From f1cd2e3d42a22603e9c039b044c57a74fbb77775 Mon Sep 17 00:00:00 2001 From: subochev Date: Tue, 15 Sep 2026 13:29:19 +0300 Subject: [PATCH] =?UTF-8?q?litert-8:=20=D1=82=D1=83=D0=BB-=D1=86=D0=B8?= =?UTF-8?q?=D0=BA=D0=BB=20=D0=B1=D0=B5=D0=B7=20=D1=84=D0=B0=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=20trigger-=D1=81=D0=BE?= =?UTF-8?q?=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit litert-api 7 -> 8. addToolResult(callId, name, result: Unit) -> LiteDelta (несёт текст пост-тул ответа модели + возможные вложенные tool-calls). caffeine не публикует parent-аггрегатор, поэтому алиасы в libs.versions.toml указывают на -jvm flavor напрямую. ChatConversation.runTurn: убран хак currentParts=[Text(' ')] — вместо него runToolAndPersist(call) -> (callId, resultText) -> addToolResult() возвращает LiteDelta, цикл идёт по delta.toolCalls. Никакого 'призрачного' ответа модели в KV-cache после каждого тула. Live smoke-test (gemma-4-E2B + SigLIP vector backend): - 'Запомни: работаю на macOS' -> 'Я сохранил информацию о том, что вы работаете на macOS' (раньше: 'Чем я могу помочь?') - 'На чём работаю?' -> 'Вы работаете на macOS' - цепочка имя->а necdoт -> модель осмысленно продолжает, не сбрасывается - тесты: 264/264 зелёных --- gradle/libs.versions.toml | 4 +- settings.gradle.kts | 4 +- .../standalone/agent/ChatConversation.kt | 90 +++++++++++++------ .../agentik/standalone/agent/ChatAgentTest.kt | 27 +++--- .../agentik/standalone/agent/FakeLiteLlm.kt | 3 +- 5 files changed, 82 insertions(+), 46 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index add7f14..2102221 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ kotlinx-io = "0.8.0" ktor = "3.1.3" a2a = "1.0.0-SNAPSHOT" kaml = "0.104.0" -litert = "7" +litert = "8" sqldelight = "2.3.2" shadow = "8.3.5" jvector = "3.0.6" @@ -32,7 +32,7 @@ kaml = { module = "com.charleskorn.kaml:kaml", version.ref = "kaml" } # --- litert-kmp (pw.binom.litert) — universal LLM wrapper --- litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" } -litert-openai = { module = "pw.binom.litert:litert-openai-jvm", version.ref = "litert" } +litert-openai = { module = "pw.binom.litert:litert-openai", version.ref = "litert" } litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert" } # --- SQLDelight (app.cash.sqldelight) — KMP SQLite, JDBC driver --- diff --git a/settings.gradle.kts b/settings.gradle.kts index 0399a54..5de7ea9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,10 +10,12 @@ pluginManagement { dependencyResolutionManagement { repositories { + // Сначала mavenLocal — чтобы локально опубликованные версии + // (через publishToMavenLocal) перекрывали caffeine. + mavenLocal() mavenCentral() google() // Локально опубликованные snapshot-ы text-embedding-kmp (см. `~/.m2`). - mavenLocal() // Home Nexus, репо "caffeine": pw.binom.* (A2A, ...) maven { name = "caffeine" 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 0faa1ca..822d1d8 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 @@ -235,9 +235,13 @@ class ChatConversation( * Один ход: user → (assistant → tool → ... → assistant)*. * * Tool-loop: после каждого `sendStreamContents` смотрим `delta.toolCalls`. Если есть — - * исполняем, подаём результат через `addToolResult`, делаем ещё один send (с пустым - * user-сообщением как триггером продолжения — модель уже знает, что делать дальше - * по tool-results в истории), повторяем. Защита от зацикливания — [MAX_TOOL_LOOPS]. + * исполняем, подаём результат через `addToolResult` (он возвращает LiteDelta с + * текстом пост-тул ответа модели + возможными вложенными tool-calls). Цикл + * завершается, когда движок возвращает пустую дельту. Защита от зацикливания — [MAX_TOOL_LOOPS]. + * + * В отличие от старого "void addToolResult + sendStreamContents(" ")" — здесь + * нет фантомного trigger-сообщения: LiteDelta из addToolResult несёт и текст + * и nested tool-calls, и мы их тут же обрабатываем. */ private suspend fun runTurn(userRecord: MessageRecord.UserMessage, turnStarted: Instant) { if (!record.isTemporal) { @@ -291,37 +295,67 @@ class ChatConversation( val tokensAtTurnStart: Int? = readTokenCount(liteConv) var turnTokens: TurnTokens? = null + var pendingParts: List? = currentParts while (loopGuard++ < MAX_TOOL_LOOPS) { + // 1) Initial user message: send full text, model may respond with + // text + toolCalls. Subsequent iterations: pendingParts = null → + // skip send, drive via addToolResult loop below. val collectedCalls = mutableListOf() - try { - liteConv.sendStreamContents(currentParts).collect { delta -> + if (pendingParts != null) { + try { + liteConv.sendStreamContents(pendingParts!!).collect { delta -> + if (delta.text.isNotEmpty()) { + reply.append(delta.text) + emitEvent(ProtoEvent.AppendText(date = now(), body = delta.text)) + } + if (delta.toolCalls.isNotEmpty()) { + collectedCalls.addAll(delta.toolCalls) + } + } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Throwable) { + this.liteConv = null + failTurn(e.message ?: e.javaClass.simpleName) + return + } + pendingParts = null + } + + // 2) Tool-loop: process collected tool calls. After each tool, feed + // the result back via addToolResult (returns LiteDelta — text + + // possibly nested toolCalls). Cycle exits when model no longer + // requests tools. + var nextCalls = collectedCalls + while (nextCalls.isNotEmpty()) { + val prev = nextCalls + nextCalls = mutableListOf() + for (call in prev) { + val (callId, resultText) = runToolAndPersist(call) + val delta = try { + liteConv.addToolResult(callId = callId, name = call.name, result = resultText) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Throwable) { + this.liteConv = null + failTurn(e.message ?: e.javaClass.simpleName) + return + } if (delta.text.isNotEmpty()) { reply.append(delta.text) emitEvent(ProtoEvent.AppendText(date = now(), body = delta.text)) } if (delta.toolCalls.isNotEmpty()) { - collectedCalls.addAll(delta.toolCalls) + nextCalls.addAll(delta.toolCalls) } } - } catch (e: kotlinx.coroutines.CancellationException) { - throw e - } catch (e: Throwable) { - this.liteConv = null - failTurn(e.message ?: e.javaClass.simpleName) - return } - if (collectedCalls.isEmpty()) break - - for (call in collectedCalls) { - executeToolCall(liteConv, call) - } - - // Continuation: send a no-op user message so the engine produces the next - // assistant response (which will see the tool results we just fed via - // addToolResult in its history). The leading newline + space is a benign - // trigger — every LLM treats it as "please continue". - currentParts = listOf(LiteContentPart.Text(" ")) + if (nextCalls.isEmpty() && pendingParts == null) break + // (pendingParts != null случай обработан выше; сюда попадём только + // если executeToolCall сам породил вложенный tool-loop и мы хотим + // продолжить — но мы это уже разрулили внутренним while выше.) + if (nextCalls.isEmpty()) break } if (loopGuard >= MAX_TOOL_LOOPS) { @@ -734,10 +768,12 @@ class ChatConversation( } /** - * Один tool-call: эмитим Event.ToolCall, выполняем tool (MCP), эмитим Event.ToolResult, - * пишем в audit + working memory, подаём результат в LiteConversation. + * Исполняет tool-call: эмитит [ProtoEvent.ToolCall]/[ProtoEvent.ToolResult], + * пишет в audit + working memory, возвращает пару (callId, текст результата). + * Сам `addToolResult` делает вызывающий — нам нужен callId, который иначе + * негде взять (в LiteToolCall id отсутствует). */ - private suspend fun executeToolCall(liteConv: LiteConversation, call: LiteToolCall) { + private suspend fun runToolAndPersist(call: LiteToolCall): Pair { val callId = newId("tc") val resultId = newId("tr") val argsJson = encodeArgsJson(call.arguments) @@ -785,7 +821,7 @@ class ChatConversation( ) } - liteConv.addToolResult(callId = callId, name = call.name, result = resultText) + return callId to resultText } /** 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 f91932f..b4787d7 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 @@ -482,27 +482,24 @@ private class ToolLoopFakeLiteLlm : LiteLlm { override fun sendStream(prompt: String) = sendStreamContents(listOf(LiteContentPart.Text(prompt))) override fun sendStreamContents(contents: List): Flow { hist.add(LiteMessage(LiteRole.USER, contents)) - return if (toolCallCount == 0) { - toolCallCount++ - flowOf( - LiteDelta( - text = "", - isDone = true, - toolCalls = listOf(LiteToolCall(name = "echo", arguments = mapOf("x" to "hi"))), - ), - ) - } else { - val reply = "final reply" - finalReplyEmitted = reply - flowOf(LiteDelta(text = reply, isDone = true)) - } + toolCallCount++ + return flowOf( + LiteDelta( + text = "", + isDone = true, + toolCalls = listOf(LiteToolCall(name = "echo", arguments = mapOf("x" to "hi"))), + ), + ) } override fun send(prompt: String): String = "unused" override fun sendContents(contents: List): String = "unused" override fun cancel() {} override fun tokenCount(): Int = hist.size - override fun addToolResult(callId: String?, name: String, result: String) { + override fun addToolResult(callId: String?, name: String, result: String): LiteDelta { lastToolResult = result + val reply = "final reply" + finalReplyEmitted = reply + return LiteDelta(text = reply, isDone = true) } override fun close() {} } diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/FakeLiteLlm.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/FakeLiteLlm.kt index 77595ba..1aac416 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/FakeLiteLlm.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/FakeLiteLlm.kt @@ -87,6 +87,7 @@ internal class FakeLiteConversation( } override fun cancel() {} override fun tokenCount(): Int = history.size - override fun addToolResult(callId: String?, name: String, result: String) { error("not used") } + override fun addToolResult(callId: String?, name: String, result: String): LiteDelta = + LiteDelta(text = "", isDone = true) override fun close() {} }