litert-8: тул-цикл без фантомного trigger-сообщения

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 зелёных
This commit is contained in:
2026-09-15 13:29:19 +03:00
parent 135c6a419d
commit f1cd2e3d42
5 changed files with 82 additions and 46 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ kotlinx-io = "0.8.0"
ktor = "3.1.3" ktor = "3.1.3"
a2a = "1.0.0-SNAPSHOT" a2a = "1.0.0-SNAPSHOT"
kaml = "0.104.0" kaml = "0.104.0"
litert = "7" litert = "8"
sqldelight = "2.3.2" sqldelight = "2.3.2"
shadow = "8.3.5" shadow = "8.3.5"
jvector = "3.0.6" 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-kmp (pw.binom.litert) — universal LLM wrapper ---
litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" } 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" } litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert" }
# --- SQLDelight (app.cash.sqldelight) — KMP SQLite, JDBC driver --- # --- SQLDelight (app.cash.sqldelight) — KMP SQLite, JDBC driver ---
+3 -1
View File
@@ -10,10 +10,12 @@ pluginManagement {
dependencyResolutionManagement { dependencyResolutionManagement {
repositories { repositories {
// Сначала mavenLocal — чтобы локально опубликованные версии
// (через publishToMavenLocal) перекрывали caffeine.
mavenLocal()
mavenCentral() mavenCentral()
google() google()
// Локально опубликованные snapshot-ы text-embedding-kmp (см. `~/.m2`). // Локально опубликованные snapshot-ы text-embedding-kmp (см. `~/.m2`).
mavenLocal()
// Home Nexus, репо "caffeine": pw.binom.* (A2A, ...) // Home Nexus, репо "caffeine": pw.binom.* (A2A, ...)
maven { maven {
name = "caffeine" name = "caffeine"
@@ -235,9 +235,13 @@ class ChatConversation(
* Один ход: user → (assistant → tool → ... → assistant)*. * Один ход: user → (assistant → tool → ... → assistant)*.
* *
* Tool-loop: после каждого `sendStreamContents` смотрим `delta.toolCalls`. Если есть — * Tool-loop: после каждого `sendStreamContents` смотрим `delta.toolCalls`. Если есть —
* исполняем, подаём результат через `addToolResult`, делаем ещё один send (с пустым * исполняем, подаём результат через `addToolResult` (он возвращает LiteDelta с
* user-сообщением как триггером продолжения — модель уже знает, что делать дальше * текстом пост-тул ответа модели + возможными вложенными tool-calls). Цикл
* по tool-results в истории), повторяем. Защита от зацикливания — [MAX_TOOL_LOOPS]. * завершается, когда движок возвращает пустую дельту. Защита от зацикливания — [MAX_TOOL_LOOPS].
*
* В отличие от старого "void addToolResult + sendStreamContents(" ")" — здесь
* нет фантомного trigger-сообщения: LiteDelta из addToolResult несёт и текст
* и nested tool-calls, и мы их тут же обрабатываем.
*/ */
private suspend fun runTurn(userRecord: MessageRecord.UserMessage, turnStarted: Instant) { private suspend fun runTurn(userRecord: MessageRecord.UserMessage, turnStarted: Instant) {
if (!record.isTemporal) { if (!record.isTemporal) {
@@ -291,37 +295,67 @@ class ChatConversation(
val tokensAtTurnStart: Int? = readTokenCount(liteConv) val tokensAtTurnStart: Int? = readTokenCount(liteConv)
var turnTokens: TurnTokens? = null var turnTokens: TurnTokens? = null
var pendingParts: List<LiteContentPart>? = currentParts
while (loopGuard++ < MAX_TOOL_LOOPS) { 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<LiteToolCall>() val collectedCalls = mutableListOf<LiteToolCall>()
try { if (pendingParts != null) {
liteConv.sendStreamContents(currentParts).collect { delta -> 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()) { if (delta.text.isNotEmpty()) {
reply.append(delta.text) reply.append(delta.text)
emitEvent(ProtoEvent.AppendText(date = now(), body = delta.text)) emitEvent(ProtoEvent.AppendText(date = now(), body = delta.text))
} }
if (delta.toolCalls.isNotEmpty()) { 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 if (nextCalls.isEmpty() && pendingParts == null) break
// (pendingParts != null случай обработан выше; сюда попадём только
for (call in collectedCalls) { // если executeToolCall сам породил вложенный tool-loop и мы хотим
executeToolCall(liteConv, call) // продолжить — но мы это уже разрулили внутренним while выше.)
} if (nextCalls.isEmpty()) break
// 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 (loopGuard >= MAX_TOOL_LOOPS) { if (loopGuard >= MAX_TOOL_LOOPS) {
@@ -734,10 +768,12 @@ class ChatConversation(
} }
/** /**
* Один tool-call: эмитим Event.ToolCall, выполняем tool (MCP), эмитим Event.ToolResult, * Исполняет tool-call: эмитит [ProtoEvent.ToolCall]/[ProtoEvent.ToolResult],
* пишем в audit + working memory, подаём результат в LiteConversation. * пишет в audit + working memory, возвращает пару (callId, текст результата).
* Сам `addToolResult` делает вызывающий — нам нужен callId, который иначе
* негде взять (в LiteToolCall id отсутствует).
*/ */
private suspend fun executeToolCall(liteConv: LiteConversation, call: LiteToolCall) { private suspend fun runToolAndPersist(call: LiteToolCall): Pair<String, String> {
val callId = newId("tc") val callId = newId("tc")
val resultId = newId("tr") val resultId = newId("tr")
val argsJson = encodeArgsJson(call.arguments) val argsJson = encodeArgsJson(call.arguments)
@@ -785,7 +821,7 @@ class ChatConversation(
) )
} }
liteConv.addToolResult(callId = callId, name = call.name, result = resultText) return callId to resultText
} }
/** /**
@@ -482,27 +482,24 @@ private class ToolLoopFakeLiteLlm : LiteLlm {
override fun sendStream(prompt: String) = sendStreamContents(listOf(LiteContentPart.Text(prompt))) override fun sendStream(prompt: String) = sendStreamContents(listOf(LiteContentPart.Text(prompt)))
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> { override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
hist.add(LiteMessage(LiteRole.USER, contents)) hist.add(LiteMessage(LiteRole.USER, contents))
return if (toolCallCount == 0) { toolCallCount++
toolCallCount++ return flowOf(
flowOf( LiteDelta(
LiteDelta( text = "",
text = "", isDone = true,
isDone = true, toolCalls = listOf(LiteToolCall(name = "echo", arguments = mapOf("x" to "hi"))),
toolCalls = listOf(LiteToolCall(name = "echo", arguments = mapOf("x" to "hi"))), ),
), )
)
} else {
val reply = "final reply"
finalReplyEmitted = reply
flowOf(LiteDelta(text = reply, isDone = true))
}
} }
override fun send(prompt: String): String = "unused" override fun send(prompt: String): String = "unused"
override fun sendContents(contents: List<LiteContentPart>): String = "unused" override fun sendContents(contents: List<LiteContentPart>): String = "unused"
override fun cancel() {} override fun cancel() {}
override fun tokenCount(): Int = hist.size 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 lastToolResult = result
val reply = "final reply"
finalReplyEmitted = reply
return LiteDelta(text = reply, isDone = true)
} }
override fun close() {} override fun close() {}
} }
@@ -87,6 +87,7 @@ internal class FakeLiteConversation(
} }
override fun cancel() {} override fun cancel() {}
override fun tokenCount(): Int = history.size 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() {} override fun close() {}
} }