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
@@ -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<LiteContentPart>? = 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<LiteToolCall>()
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<String, String> {
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
}
/**
@@ -482,27 +482,24 @@ private class ToolLoopFakeLiteLlm : LiteLlm {
override fun sendStream(prompt: String) = sendStreamContents(listOf(LiteContentPart.Text(prompt)))
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
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<LiteContentPart>): 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() {}
}
@@ -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() {}
}