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:
@@ -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 ---
|
||||
|
||||
+3
-1
@@ -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"
|
||||
|
||||
+63
-27
@@ -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() {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user