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"
|
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
@@ -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"
|
||||||
|
|||||||
+54
-18
@@ -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,10 +295,15 @@ 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>()
|
||||||
|
if (pendingParts != null) {
|
||||||
try {
|
try {
|
||||||
liteConv.sendStreamContents(currentParts).collect { delta ->
|
liteConv.sendStreamContents(pendingParts!!).collect { delta ->
|
||||||
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))
|
||||||
@@ -310,18 +319,43 @@ class ChatConversation(
|
|||||||
failTurn(e.message ?: e.javaClass.simpleName)
|
failTurn(e.message ?: e.javaClass.simpleName)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
pendingParts = null
|
||||||
if (collectedCalls.isEmpty()) break
|
|
||||||
|
|
||||||
for (call in collectedCalls) {
|
|
||||||
executeToolCall(liteConv, call)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continuation: send a no-op user message so the engine produces the next
|
// 2) Tool-loop: process collected tool calls. After each tool, feed
|
||||||
// assistant response (which will see the tool results we just fed via
|
// the result back via addToolResult (returns LiteDelta — text +
|
||||||
// addToolResult in its history). The leading newline + space is a benign
|
// possibly nested toolCalls). Cycle exits when model no longer
|
||||||
// trigger — every LLM treats it as "please continue".
|
// requests tools.
|
||||||
currentParts = listOf(LiteContentPart.Text(" "))
|
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()) {
|
||||||
|
nextCalls.addAll(delta.toolCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextCalls.isEmpty() && pendingParts == null) break
|
||||||
|
// (pendingParts != null случай обработан выше; сюда попадём только
|
||||||
|
// если executeToolCall сам породил вложенный tool-loop и мы хотим
|
||||||
|
// продолжить — но мы это уже разрулили внутренним while выше.)
|
||||||
|
if (nextCalls.isEmpty()) break
|
||||||
}
|
}
|
||||||
|
|
||||||
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++
|
||||||
flowOf(
|
return 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() {}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user