Внедряем единый тул-канал для локального LiteRT и удалённого LLM-клиента

This commit is contained in:
2026-09-02 02:42:00 +03:00
parent d5e60a2565
commit edf7cf63f6
8 changed files with 626 additions and 99 deletions
@@ -246,8 +246,11 @@ class PhoneApp : Application() {
).also { toolkit = it }
return Assistant(
chatDao = chatDao,
chat = { history, system -> llm.chat(history, system) },
chat = { history, toolSpecs, system -> llm.chatWithTools(history, toolSpecs, system) },
toolkit = tk,
// Нативный тул-канал LiteRT: только локальная модель видит схемы
// тул у движка; системный промпт тогда без текстовых TOOL_CALL-линий.
nativeTools = choice == LlmModelChoice.LOCAL,
// Локальной модели нужно время: инициализация движка ~17с на
// CPU + генерация. Серверная укладывается в 30с, локальной
// даём 5 минут (иначе withTimeoutOrNull режет генерацию).
@@ -54,15 +54,24 @@ class AgentToolkit(
* Системный промпт для сессии: ядро + каталог + активные тулсеты (с
* описаниями тул) + индекс скиллов (свои скиллы активных тулсетов убраны).
* Просроченные тулсеты чистятся здесь же — фоновых таймеров нет.
*
* [nativeTools] — нативный тул-канал (локальная модель, LiteRT): тулы
* модель видит через JSON-схемы в движке, строку текстового протокола
* TOOL_CALL в промпт НЕ кладем (иначе 2B-модель путает каналы).
*/
fun systemPrompt(sessionId: Long): String {
fun systemPrompt(sessionId: Long, nativeTools: Boolean = false): String {
val note = pendingNotes.remove(sessionId)
val activeSets = toolsets.activeNames(sessionId)
val sb = StringBuilder()
sb.append("Ты — ассистент Viewmate: краткий помощник на телефоне, связанный с очками Viewmate. ")
.append("Делай то, что просит пользователь, отвечай кратко (1-3 предложения), по-русски.")
sb.append("\n\nДля тула ответь ровно одной строкой TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2 (без кавычек, без текста вокруг). ")
.append("После получения результата тула ответь пользователю текстом.")
if (nativeTools) {
sb.append("\n\nДля тула используй вызов инструмента из списка доступных тул (function calling). ")
.append("После получения результата тула ответь пользователю текстом.")
} else {
sb.append("\n\nДля тула ответь ровно одной строкой TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2 (без кавычек, без текста вокруг). ")
.append("После получения результата тула ответь пользователю текстом.")
}
sb.append("\n\nЯдро:")
CORE_LINES.forEach { sb.append('\n').append(it) }
val catalog = toolsets.catalogLines()
@@ -85,6 +94,27 @@ class AgentToolkit(
return sb.toString()
}
/**
* Нейтральные схемы тул для [LlmClient.chatWithTools] (нативный канал):
* ядро (5 тул) + тулы АКТИВНЫХ тулсетов (с полными именами `набор_тул`).
* Неактивные тулсеты сюда не попадают — модель увидит их только после
* enable_toolset (в следующем callLlm схема пересобирается).
*/
fun toolSpecs(sessionId: Long): List<ToolSpec> {
val out = mutableListOf(
ToolSpec(TOOL_TIME, "Текущие дата, время и часовой пояс (ISO 8601)"),
ToolSpec(TOOL_SHOW_TEXT, "Показать текст на очках", listOf("text")),
ToolSpec(TOOL_READ_SKILL, "Прочитать полное содержимое скилла по имени", listOf("name")),
ToolSpec(TOOL_ENABLE, "Активировать тулсет (живёт 10 мин от последнего вызова его тул)", listOf("name")),
ToolSpec(TOOL_DISABLE, "Деактивировать тулсет", listOf("name")),
)
for (setName in toolsets.activeNames(sessionId)) {
val ts = toolsets.toolset(setName) ?: continue
ts.tools.forEach { s -> out += ToolSpec(ts.toolFull(s), s.description, s.args) }
}
return out
}
// ---------- парсинг аргументов ----------
/** Схема (имена аргументов) для тула — чтобы распознать «голое» значение. */
@@ -98,31 +128,11 @@ class AgentToolkit(
/**
* Аргументы протокола `TOOL_CALL: имя|a=b,c=d` (или «голое» значение для
* одноаргументных тул) → карта. Прощающий: пары — через запятую/`&`,
* лишние ключи не ошибка. Пустая строка/нет аргументов — пустая карта.
* одноаргументных тул) → карта. Логика — в топовой [parseToolCallArgs]
* (общая с RemoteLlmClient), метод оставлен для API/тестов.
*/
fun parseArgs(raw: String?, expected: List<String>): Map<String, String> {
val s = raw?.trim()
if (s.isNullOrEmpty()) return emptyMap()
val required = expected.filterNot { it.endsWith("?") }.distinct()
val out = LinkedHashMap<String, String>()
var bareIndex = 0
for (field in s.split(ALTER)) {
val f = field.trim()
if (f.isEmpty()) continue
val eq = f.indexOf('=')
if (eq > 0) {
val key = f.substring(0, eq).trim()
if (key.isNotBlank()) out[key] = f.substring(eq + 1).trim()
} else if (bareIndex < required.size) {
// «Голое» значение (media_search|Название) идёт первому обязательному аргументу.
out[required[bareIndex++]] = f
}
}
return out
}
private val ALTER = Regex("[,&]")
fun parseArgs(raw: String?, expected: List<String>): Map<String, String> =
parseToolCallArgs(raw, expected)
// ---------- диспач вызовов ----------
@@ -9,19 +9,22 @@ import kotlinx.coroutines.withTimeoutOrNull
* (сериализация фраз — LLM-вызовы не пересекаются).
*
* Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если
* передана, иначе старый [AgentTools]); если тула нужна — агент отвечает одной
* строкой `TOOL_CALL: имя|арг1=знач1,арг2=знач2` (аргументы — опционально).
* передана, иначе старый [AgentTools]). Вызовы тул нормализованы на границе
* [LlmClient]: локальный бэкенд декларует их нативным LiteRT-каналом,
* удалённый — текстовым маркером `TOOL_CALL: имя|арг1=знач1`; ассистент
* получает единый [LlmResult.toolCalls] и не знает, откуда приехали.
* Ассистент выполняет тул и возвращает результат как tool-сообщение в историю,
* затем делает повторный вызов LLM. Невидимые системные тулы
* (enable_toolset / disable_toolset) в историю НЕ пишутся — просто
* пересобирается системный промпт (состояние 3). Обычный текст (без маркера)
* пересобирается системный промпт (состояние 3). Обычный текст (без тул-вызовов)
* возвращается пользователю как финальный ответ.
*/
class Assistant(
private val chatDao: ChatDao,
private val chat: suspend (history: List<ChatMessage>, system: String?) -> String,
private val chat: suspend (history: List<ChatMessage>, tools: List<ToolSpec>, system: String?) -> LlmResult,
private val toolkit: AgentToolkit? = null,
private val tools: List<AgentTool> = AgentTools.ALL,
private val nativeTools: Boolean = false,
private val timeoutMs: Long = 30_000,
) {
/**
@@ -50,7 +53,7 @@ class Assistant(
private suspend fun processInSession(sessionId: Long): String {
val history = chatDao.messagesForSession(sessionId).takeLast(20)
var answer = callLlm(history, sessionId)
var result = callLlm(history, sessionId)
var lastToolResult: String? = null
var visibleCalls = 0
var llmCalls = 1
@@ -58,31 +61,31 @@ class Assistant(
// зацикливания агента). Невидимые (silent) вызовы лимит не съедают,
// но общее число LLM-вызовов ограничено [MAX_LLM_CALLS].
while (true) {
val parsed = parseToolCall(answer)
?: return answer // callLlm уже сохранил ASSISTANT-сообщение
val toolName = parsed.name
val call = result.toolCalls.firstOrNull()
?: return result.text // callLlm уже сохранил ASSISTANT-сообщение
val toolName = call.name
if (toolkit == null) {
// Наследственный путь (без механики тулсетов): простые тулы без аргументов.
val tool = tools.firstOrNull { it.name == toolName }
if (tool == null) {
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = result.text)
return "Тул $toolName не найден"
}
lastToolResult = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" }
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = lastToolResult, toolCallId = toolName)
logTool(sessionId, toolName, lastToolResult)
if (++visibleCalls >= MAX_TOOL_CALLS) break
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
llmCalls++
continue
}
when (val result = toolkit.dispatch(sessionId, toolName, toolkit.parseArgs(parsed.argsRaw, toolkit.schemaFor(toolName)))) {
when (val dispatch = toolkit.dispatch(sessionId, toolName, call.arguments)) {
is AgentToolkit.Dispatch.Run -> {
lastToolResult = result.result
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = result.result, toolCallId = toolName)
logTool(sessionId, toolName, result.result)
lastToolResult = dispatch.result
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = dispatch.result, toolCallId = toolName)
logTool(sessionId, toolName, dispatch.result)
if (++visibleCalls >= MAX_TOOL_CALLS) break
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
llmCalls++
}
@@ -92,29 +95,34 @@ class Assistant(
// (переход в состояние 3 — «Активные тулсеты»).
logTool(sessionId, toolName, "невидимый вызов (пересборка промпта)")
if (llmCalls++ >= MAX_LLM_CALLS) break
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
}
}
}
// LLM зациклилась на TOOL_CALL — отдаём последний результат тула как ответ,
// чтобы пользователь получил данные, а не сырой маркер.
val stillTool = parseToolCall(answer)
// LLM зациклилась на тул-вызовах — отдаём последний результат тула как
// ответ, чтобы пользователь получил данные, а не сырой маркер.
val stillTool = result.toolCalls.firstOrNull()
return if (stillTool != null && lastToolResult != null) {
"TOOL_CALL ${stillTool.name} (повтор) — $lastToolResult"
} else {
answer
result.text
}
}
private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): String {
val system = toolkit?.systemPrompt(sessionId) ?: legacySystemPrompt()
val answer = withTimeoutOrNull(timeoutMs) { chat(history, system) }
?: return "Ассистент не ответил (таймаут)"
if (!isToolCall(answer)) {
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): LlmResult {
val system = toolkit?.systemPrompt(sessionId, nativeTools) ?: legacySystemPrompt()
val specs = if (toolkit != null) {
toolkit.toolSpecs(sessionId)
} else {
tools.map { ToolSpec(name = it.name, description = it.description) }
}
val result = withTimeoutOrNull(timeoutMs) { chat(history, specs, system) }
?: return LlmResult("Ассистент не ответил (таймаут)")
if (result.toolCalls.isEmpty()) {
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = result.text)
chatDao.touchSession(sessionId)
}
return answer
return result
}
/** Системный промпт (наследственный путь, без [AgentToolkit]). */
@@ -131,23 +139,6 @@ class Assistant(
/** Общий лимит LLM-вызовов на фразу (включая невидимые). */
const val MAX_LLM_CALLS = 8
/**
* Протокол вызова тула: строка вида `TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2`
* (аргументы — опционально; простое `TOOL_CALL: имя` — без аргументов).
*/
private val TOOL_CALL_RE =
Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)(?:\s*\|\s*([^\n]*))?""", RegexOption.IGNORE_CASE)
data class ParsedToolCall(val name: String, val argsRaw: String?)
private fun parseToolCall(answer: String): ParsedToolCall? {
val m = TOOL_CALL_RE.find(answer) ?: return null
val argsRaw = m.groupValues[2].trim().ifBlank { null }
return ParsedToolCall(m.groupValues[1].lowercase(), argsRaw)
}
private fun isToolCall(answer: String): Boolean = parseToolCall(answer) != null
/** Случайное имя сессии (UUID-подобное, 8 символов). */
private fun randomTitle(): String =
"сессия-" + (1..8).map { "0123456789abcdef"[kotlin.random.Random.nextInt(16)] }.joinToString("")
@@ -9,6 +9,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import pw.binom.viewmate.phone.log
import java.util.concurrent.TimeUnit
/**
@@ -26,6 +27,106 @@ interface LlmClient {
* [system] — системный промпт тулкита/настроек или null (не обязателен).
*/
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String
/**
* Чат с тул-каналом: [tools] — схемы доступных тул ([ToolSpec]).
* Локальный клиент регистрирует их в движке (нативный LiteRT-канал),
* удалённый игнорирует список (тулы описывает текстовый протокол в промпте).
* Результат [LlmResult] несёт И текст, И структурированные вызовы
* [LlmToolCall] — клиент нормализует источник (нативный / текстовый
* маркер), [Assistant] потребляет оба бэкенда одинаково.
*/
suspend fun chatWithTools(
messages: List<ChatMessage>,
tools: List<ToolSpec> = emptyList(),
system: String? = null,
): LlmResult = LlmResult(text = chat(messages, system))
}
/**
* Нейтральная схема тула для [LlmClient.chatWithTools]: имя, описание и
* аргументы (та же нотация, что у [ToolSignature]: «query», «seek_ms?» —
* «?» = опциональный).
*/
data class ToolSpec(
val name: String,
val description: String,
val args: List<String> = emptyList(),
) {
/** Обязательные аргументы (без маркера «?»). */
fun requiredArgs(): List<String> = args.filterNot { it.endsWith("?") }.map { it.substringBefore('?') }
/** Все имена аргументов (обязательные и опциональные, без маркера «?»). */
fun argNames(): List<String> = args.map { it.substringBefore('?') }
}
/** Вызов тула, декларированный моделью: имя + распарсенные именованные аргументы. */
data class LlmToolCall(
val name: String,
val arguments: Map<String, String> = emptyMap(),
)
/** Ответ [LlmClient.chatWithTools]: текст + декларированные вызовы тул (могут быть пусты). */
data class LlmResult(
val text: String,
val toolCalls: List<LlmToolCall> = emptyList(),
)
// ---------- Текстовый протокол вызова тула (RemoteLlmClient) ----------
/**
* Строка вызова тула в текстовом протоколе: `TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2`
* (аргументы — опционально; простое `TOOL_CALL: имя` — без аргументов).
*/
val TOOL_CALL_RE =
Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)(?:\s*\|\s*([^\n]*))?""", RegexOption.IGNORE_CASE)
/**
* Аргументы протокола `TOOL_CALL: имя|a=b,c=d` (или «голое» значение для
* одноаргументных тул) → карта. Прощающий: пары — через запятую/`&`,
* лишние ключи не ошибка. Пустая строка/нет аргументов — пустая карта.
* [expected] — имена аргументов схемы ([ToolSpec.argNames]).
*/
fun parseToolCallArgs(raw: String?, expected: List<String>): Map<String, String> {
val s = raw?.trim()
if (s.isNullOrEmpty()) return emptyMap()
val required = expected.filterNot { it.endsWith("?") }.distinct()
val out = LinkedHashMap<String, String>()
var bareIndex = 0
for (field in s.split(Regex("[,&]"))) {
val f = field.trim()
if (f.isEmpty()) continue
val eq = f.indexOf('=')
if (eq > 0) {
val key = f.substring(0, eq).trim()
if (key.isNotBlank()) out[key] = f.substring(eq + 1).trim()
} else if (bareIndex < required.size) {
// «Голое» значение (media_search|Название) идёт первому обязательному аргументу.
out[required[bareIndex++]] = f
}
}
return out
}
/**
* Лог длинного текста (тела запроса, промпта, ответа) в канал [log]
* кусками ≤3500 символов — лимит записи logcat ~4096, длиннее обрезалось бы.
*/
internal fun logChunked(tag: String, label: String, text: String) {
if (text.length <= 3500) {
log(tag, "$label: $text")
return
}
val size = 3500
val total = (text.length + size - 1) / size
var from = 0
var i = 0
while (from < text.length && i < total) {
val to = minOf(from + size, text.length)
log(tag, "$label [${i + 1}/$total]: ${text.substring(from, to)}")
from = to
i++
}
}
/**
@@ -101,6 +202,8 @@ class RemoteLlmClient(
if (dto.none { it.role == "user" }) dto += ChatMessageDto("user", "Продолжай.")
val body = json.encodeToString(ChatRequest.serializer(), ChatRequest(model = model, messages = dto))
log("llm", "remote: запрос: модель=$model, сообщений=${dto.size}")
logChunked("llm", "remote: тело запроса", body)
val request = Request.Builder()
.url("$baseUrl/v1/chat/completions")
.header("Authorization", "Bearer $apiKey")
@@ -115,11 +218,34 @@ class RemoteLlmClient(
val err = runCatching { json.decodeFromString<ChatResponse>(raw).error?.message }.getOrNull()
throw IllegalStateException("HTTP ${resp.code}: ${err ?: raw.take(200)}")
}
val text = runCatching {
val reply = runCatching {
json.decodeFromString<ChatResponse>(raw).choices.firstOrNull()?.message?.content
}.getOrNull()
text?.trim() ?: ""
val text = reply?.trim() ?: ""
logChunked("llm", "remote: ответ", text.ifEmpty { "(пусто)" })
text
}
}
}
/**
* Текстовый протокол: тулы описываются в системном промпте, модель отвечает
* строкой `TOOL_CALL: имя|арг1=знач1` — маркер разбираем здесь и
* нормализуем в [LlmToolCall] (схема аргументов — из [tools], «голое»
* значение прилетает первому обязательному аргументу).
*/
override suspend fun chatWithTools(
messages: List<ChatMessage>,
tools: List<ToolSpec>,
system: String?,
): LlmResult {
val text = chat(messages, system)
val m = TOOL_CALL_RE.find(text) ?: return LlmResult(text)
val name = m.groupValues[1].lowercase()
val argsRaw = m.groupValues[2].trim().ifBlank { null }
val expected = tools.firstOrNull { it.name == name }?.argNames() ?: emptyList()
val args = parseToolCallArgs(argsRaw, expected)
log("llm", "remote: toolCall: $name (сырое='${argsRaw.orEmpty()}') → $args")
return LlmResult(text, listOf(LlmToolCall(name, args)))
}
}
@@ -2,15 +2,25 @@ package pw.binom.viewmate.phone.agent
import pw.binom.litert.LiteBackend
import pw.binom.litert.LiteConfig
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteExperimental
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteMessage
import pw.binom.litert.LiteRequest
import pw.binom.litert.LiteTool
import pw.binom.litert.LiteToolCall
import pw.binom.litert.google.googleLiteLlm
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
@@ -172,6 +182,55 @@ fun buildLocalLlmPrompt(messages: List<ChatMessage>, system: String? = null): Lo
)
}
/**
* OpenAPI-JSON-дескриптор [ToolSpec] для LiteRT-LM: ПЛОСКИЙ объект
* `{"name","description","parameters"}` — так его читает движок
* (litertlm 0.16.1, ToolKt.tool: `json.get("name").getAsString()` на
* верхнем уровне; OpenAI-обёртка `{"type":"function","function":{…}}`
* падает ToolException «Failed to parse field name»). Движок передаёт
* строку модели КАК ЕСТЬ — без репарсинга и валидации (контракт тул-слоя
* pw.binom.litert, tag 5), поэтому JSON строим сами.
* Все аргументы у нас строковые (текстовый протокол тулкита), `type` — «string».
*/
fun toolJson(spec: ToolSpec): String = buildJsonObject {
put("name", spec.name)
put("description", spec.description)
putJsonObject("parameters") {
put("type", "object")
if (spec.argNames().isNotEmpty()) {
putJsonObject("properties") {
spec.args.forEach { a ->
putJsonObject(a.substringBefore('?')) {
put("type", "string")
}
}
}
val required = spec.requiredArgs()
if (required.isNotEmpty()) {
putJsonArray("required") {
required.forEach { add(JsonPrimitive(it)) }
}
}
}
}
}.toString()
/**
* Наш [ToolSpec] в виде [LiteTool] LiteRT-слоя: `describe()` — самособранное
* OpenAPI-JSON (плоский, для LiteRT-LM) из [toolJson]. `invoke()` недостижим: в ручном режиме
* (automaticToolCalling = false) тул-цикл ведёт [Assistant], движок не
* исполняет тулы — если движок всё же позвал `invoke`, это баг движка,
* падаем с понятным сообщением.
*/
private class SpecLiteTool(spec: ToolSpec) : LiteTool {
private val descriptor: String = toolJson(spec)
override fun describe(): String = descriptor
override fun invoke(arguments: String): String =
error("LiteTool.invoke: тул-цикл ведёт Assistant (automaticToolCalling = false), движок не должен исполнять тулы")
}
// ---------- Скачивание модели ----------
/** Состояние локальной модели для UI настроек (StateFlow в PhoneApp). */
@@ -289,9 +348,9 @@ class GemmaModelDownloader(
*
* Жизненный цикл: [LiteLlm] создаётся один раз (лениво, при первом [chat]),
* переиспользуется — модель заново не грузится. Каждый чатовый вызов —
* новый RAW-инференс: [LiteRequest] несёт полный промпт (системная
* инструкция + вся история + входящее), движок не хранит состояние между
* вызовами. Ошибки — [IllegalStateException] с читаемым русским
* новый RAW-инференс: [LiteConversationConfig] несёт полный промпт
* (системная инструкция + вся история + входящее) и схемы тул,
* движок не хранит состояние между вызовами. Ошибки — [IllegalStateException] с читаемым русским
* сообщением («локальная модель не скачана…») — [Assistant] покажет его
* пользователю, падение приложения исключено.
*
@@ -310,43 +369,87 @@ class LocalLlmClient(
/** Модель на месте (по имени файла; SHA-контроль — в [ensureLlm]). */
fun modelDownloaded(): Boolean = modelFile.isFile
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
log("llm", "chat: вызов (messages=${messages.size}, system=${system?.length ?: 0} симв)")
override suspend fun chat(messages: List<ChatMessage>, system: String?): String =
chatWithTools(messages, emptyList(), system).text
/**
* Чат с тул-каналом: [tools] регистрируются в движке нативным LiteRT-каналом
* (JSON-схемы [toolJson] → модели; вызовы → [LlmResult.toolCalls]).
* Ручной режим: движок тулы НЕ исполняет (automaticToolCalling = false) —
* цикл «вызов → dispatch → результат в историю → повторный инференс»
* ведёт [Assistant]; каждый круг — новый RAW-инференс.
*/
override suspend fun chatWithTools(
messages: List<ChatMessage>,
tools: List<ToolSpec>,
system: String?,
): LlmResult {
log("llm", "chat: вызов (messages=${messages.size}, tools=${tools.size}, system=${system?.length ?: 0} симв)")
val llm = ensureLlm()
log("llm", "chat: движок готов, строю промпт")
val prompt = buildLocalLlmPrompt(messages, system)
log("llm", "chat: промпт: system=${prompt.systemInstruction?.length ?: 0} симв, initial=${prompt.initialMessages.size}, incoming=${prompt.incoming.text.take(60)}")
val request = LiteRequest(
// Полный контент, уходящий в модель: системный промпт, история, входящее, схемы тул
logChunked("llm", "local: system", prompt.systemInstruction ?: "(нет)")
prompt.initialMessages.forEachIndexed { i, m ->
logChunked("llm", "local: msg[$i/${prompt.initialMessages.size}] ${m.role}", m.text)
}
logChunked("llm", "local: incoming", prompt.incoming.text)
tools.forEachIndexed { i, spec ->
log("llm", "local: tool[$i/${tools.size}] ${spec.name}: ${toolJson(spec)}")
}
val config = LiteConversationConfig(
systemInstruction = prompt.systemInstruction,
// RAW-режим: владелец истории — мы; каждый инференс — новый полный промпт
messages = prompt.initialMessages + prompt.incoming,
initialMessages = prompt.initialMessages + prompt.incoming,
tools = tools.map { SpecLiteTool(it) },
// Ручной режим: исполняют тулы не движок, а Assistant (см. KDoc метода)
automaticToolCalling = false,
)
var text = ""
var toolCalls = emptyList<LiteToolCall>()
var chunks = 0
var tokens = 0
val t0 = System.currentTimeMillis()
var firstTokenAt = 0L
var conversation: LiteConversation? = null
try {
withTimeout(120_000) {
// либа сама закрывает беседу по завершении/отмене потока
llm.inferStream(request).collect { delta ->
chunks++
val chunkText = delta.text
text += chunkText
if (chunkText.isNotEmpty()) {
if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis()
tokens += 1
conversation = llm.createConversation(config)
// Поток либы сам себя не завершает (callbackFlow живёт до отмены):
// остановка — по терминальной дельте isDone (см. KDoc LiteDelta).
conversation.sendStream(prompt.incoming.text)
.onEach { delta ->
chunks++
val chunkText = delta.text
text += chunkText
if (chunkText.isNotEmpty()) {
if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis()
tokens += 1
}
if (delta.toolCalls.isNotEmpty()) toolCalls = delta.toolCalls
if (chunks <= 3 || chunks % 20 == 0) {
log("llm", "chat: чанк #$chunks: ${chunkText.take(50)}${if (delta.toolCalls.isNotEmpty()) " [тулы: ${delta.toolCalls.map { it.name }}]" else ""}")
}
}
if (chunks <= 3 || chunks % 20 == 0) {
log("llm", "chat: чанк #$chunks: ${chunkText.take(50)}")
}
}
.takeWhile { !it.isDone }
.collect { }
}
val totalMs = System.currentTimeMillis() - t0
val ttftMs = if (firstTokenAt > 0L) firstTokenAt - t0 else totalMs
val tokPerSec = if (totalMs > 0) (tokens * 1000.0 / totalMs) else 0.0
log("llm", "chat: готово, чанков=$chunks, симв=${text.length}, TTFB=${ttftMs}мс, ~${String.format(java.util.Locale.US, "%.1f", tokPerSec)} чанк/с (всего ${totalMs}мс)")
return text.trim()
log("llm", "chat: готово, чанков=$chunks, симв=${text.length}, TTFB=${ttftMs}мс, ~${String.format(java.util.Locale.US, "%.1f", tokPerSec)} чанк/с, тул-вызовов=${toolCalls.size} (всего ${totalMs}мс)")
val normalized = toolCalls.map { call ->
LlmToolCall(
name = call.name,
arguments = call.arguments.mapValues { entry -> entry.value?.toString() ?: "" },
)
}
logChunked("llm", "local: ответ", text.trim().ifEmpty { "(пусто)" })
normalized.forEach { call ->
log("llm", "local: toolCall: ${call.name} аргументы=${call.arguments}")
}
return LlmResult(text.trim(), normalized)
} catch (e: CancellationException) {
log("llm", "chat: отменено")
throw e
@@ -354,6 +457,10 @@ class LocalLlmClient(
log("llm", "chat: EXC class=${e::class.simpleName} msgLen=${e.message?.length}")
log("llm", "chat: EXC msg=${sanitize(e.message)}")
throw IllegalStateException("Локальная модель не ответила: ${e.message}", e)
} finally {
// либа (RAW-путь) закрывает беседу сама, здесь — явный close для
// прямого createConversation: каждый вызов — своя беседа
runCatching { conversation?.close() }
}
}
@@ -0,0 +1,164 @@
package pw.binom.viewmate.phone
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import pw.binom.viewmate.phone.agent.ChatMessage
import pw.binom.viewmate.phone.agent.ChatRole
import pw.binom.viewmate.phone.agent.LlmClient
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/** OpenAI-HTTP-сервер (Ktor): /v1/chat/completions (stream=false|true), маппинг ролей, ошибки. */
class OpenAiHttpServerTest {
private val json = Json
private class FakeLlm(
private val text: String = "Привет, мир!",
private val failure: String? = null,
) : LlmClient {
var calls = 0
var lastSystem: String? = null
var lastMessages: List<ChatMessage> = emptyList()
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
calls++
lastSystem = system
lastMessages = messages
failure?.let { throw IllegalStateException(it) }
return text
}
}
private val chatBody =
"""{"model":"any","stream":false,"messages":[{"role":"user","content":"Привет"}]}"""
private fun call(body: String, llm: LlmClient?): OpenAiHttpServer.ChatOutcome =
runBlocking { OpenAiHttpServer.handleChat(llm, body) }
@Test
fun nonStream_returnsCompletionJson() {
val llm = FakeLlm()
val outcome = call(chatBody, llm) as OpenAiHttpServer.ChatOutcome.Json
assertEquals(200, outcome.code)
val body = outcome.body
assertTrue(body.contains("\"object\":\"chat.completion\""))
assertTrue(body.contains("\"content\":\"Привет, мир!\""))
assertTrue(body.contains("\"finish_reason\":\"stop\""))
assertTrue(body.contains("\"id\":\"chatcmpl-"))
assertEquals(1, llm.calls)
assertEquals(listOf(ChatMessage.of(ChatRole.USER, "Привет")), llm.lastMessages)
assertEquals(null, llm.lastSystem)
}
@Test
fun systemAndDeveloperRoles_goToSystem() {
val llm = FakeLlm()
call(
"""{"messages":[{"role":"system","content":"S1"},{"role":"developer","content":"S2"},{"role":"user","content":"U"},{"role":"assistant","content":"A"}]}""",
llm,
)
assertEquals("S1\nS2", llm.lastSystem)
assertEquals(listOf(ChatRole.USER, ChatRole.ASSISTANT), llm.lastMessages.map { it.role })
}
@Test
fun toolRole_mapsToToolMessage() {
val llm = FakeLlm()
call(
"""{"messages":[{"role":"user","content":"U"},{"role":"tool","tool_call_id":"t1","content":"результат"}]}""",
llm,
)
val tool = llm.lastMessages.last()
assertEquals(ChatRole.TOOL, tool.role)
assertEquals("t1", tool.toolCallId)
}
@Test
fun contentAsArrayOfTextParts_joined() {
val llm = FakeLlm()
call(
"""{"messages":[{"role":"user","content":[{"type":"text","text":"часть1"},{"type":"text","text":"часть2"}]}]}""",
llm,
)
assertEquals("часть1\nчасть2", llm.lastMessages.last().content)
}
@Test
fun assistantNullContentWithToolCalls_decodes() {
val llm = FakeLlm()
call(
"""{"messages":[{"role":"user","content":"U"},{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}]}]}""",
llm,
)
val roles = llm.lastMessages.map { it.role }
assertEquals(listOf(ChatRole.USER, ChatRole.ASSISTANT), roles)
}
@Test
fun stream_emitsSseChunksAndDone() {
val llm = FakeLlm()
val outcome = call(
"""{"model":"any","stream":true,"messages":[{"role":"user","content":"Привет"}]}""",
llm,
) as OpenAiHttpServer.ChatOutcome.Sse
val data = outcome.frames
assertTrue(data.any { it.endsWith("[DONE]\n\n") || it == "data: [DONE]\n\n" })
val chunks = data.filter { !it.contains("[DONE]") }.map {
json.decodeFromString(JsonElement.serializer(), it.removePrefix("data: ").trim())
}
val choices = chunks.map { chunk ->
chunk.jsonObject["choices"]!!.jsonArray[0].jsonObject
}
val deltas = choices.map { it["delta"]!!.jsonObject }
assertEquals("assistant", deltas.first()["role"]!!.jsonPrimitive.content)
val full = deltas.mapNotNull { it["content"]?.jsonPrimitive?.content }.joinToString("")
assertEquals("Привет, мир!", full)
assertEquals("stop", choices.last()["finish_reason"]!!.jsonPrimitive.content)
assertTrue(chunks.all { it.jsonObject["object"]!!.jsonPrimitive.content == "chat.completion.chunk" })
}
@Test
fun invalidBody_400() {
val outcome = call("не json", FakeLlm()) as OpenAiHttpServer.ChatOutcome.Json
assertEquals(400, outcome.code)
assertTrue(outcome.body.contains("\"error\""))
}
@Test
fun emptyMessages_400() {
val outcome = call("""{"messages":[]}""", FakeLlm()) as OpenAiHttpServer.ChatOutcome.Json
assertEquals(400, outcome.code)
}
@Test
fun unknownRole_400() {
val outcome = call("""{"messages":[{"role":"alien","content":"hi"}]}""", FakeLlm()) as OpenAiHttpServer.ChatOutcome.Json
assertEquals(400, outcome.code)
assertTrue(outcome.body.contains("\"error\""))
}
@Test
fun noLlm_503() {
val outcome = call(chatBody, null) as OpenAiHttpServer.ChatOutcome.Json
assertEquals(503, outcome.code)
assertTrue(outcome.body.contains("\"error\""))
}
@Test
fun llmFailure_502() {
val outcome = call(chatBody, FakeLlm(failure = "boom")) as OpenAiHttpServer.ChatOutcome.Json
assertEquals(502, outcome.code)
assertTrue(outcome.body.contains("boom"))
}
@Test
fun allIps_containsLoopback() {
assertTrue(OpenAiHttpServer.allIps().contains("127.0.0.1"))
}
}
@@ -0,0 +1,125 @@
package pw.binom.viewmate.phone.agent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Тесты тул-канала на границе клиента: парсер текстового маркера
* (remote) и OpenAI-JSON-описатель тула (local, нативный LiteRT-канал).
*/
class LlmClientTest {
// ---------- parseToolCallArgs (текстовый протокол) ----------
@Test
fun parseArgsEmptyWhenNoArgs() {
assertEquals(emptyMap<String, String>(), parseToolCallArgs(null, emptyList()))
assertEquals(emptyMap<String, String>(), parseToolCallArgs("", emptyList()))
}
@Test
fun parseArgsNamedAndBare() {
val args = parseToolCallArgs("query=old songs,seek_ms=30000", listOf("query", "seek_ms?"))
assertEquals("old songs", args["query"])
assertEquals("30000", args["seek_ms"])
}
@Test
fun parseArgsBareValueGoesToFirstExpected() {
val args = parseToolCallArgs("old songs", listOf("query"))
assertEquals("old songs", args["query"])
}
@Test
fun parseArgsDuplicateNameLastWins() {
val args = parseToolCallArgs("id=1,id=2", listOf("id"))
assertEquals("2", args["id"])
}
@Test
fun parseArgsUnknownNamePassedThrough() {
val args = parseToolCallArgs("id=1,foo=bar", listOf("id"))
assertEquals("1", args["id"])
assertTrue(args.containsKey("foo"))
}
@Test
fun parseArgsQuotedValueKeepsQuotes() {
// Парсер прощающий: кавычки — литеральные символы, пары режутся запятой
val args = parseToolCallArgs("""a="hello world",b=1""", listOf("a", "b"))
assertEquals("\"hello world\"", args["a"])
assertEquals("1", args["b"])
}
@Test
fun parseArgsUnknownFormatGoesToFirstRequired() {
// Неизвестный формат (без `=`) не падает: всё сырое значение
// уходит первому обязательному аргументу
val args = parseToolCallArgs("""{"id":"c42"}""", listOf("id"))
assertEquals("""{"id":"c42"}""", args["id"])
}
// ---------- TOOL_CALL-маркер (текстовый протокол, remote) ----------
@Test
fun markerFoundWithArgs() {
val m = TOOL_CALL_RE.find("хорошо, TOOL_CALL: media_search|query=old songs,seek_ms=30000")
assertNotNull(m)
assertEquals("media_search", m.groupValues[1])
assertEquals("query=old songs,seek_ms=30000", m.groupValues[2].trim())
}
@Test
fun markerFoundWithoutArgs() {
val m = TOOL_CALL_RE.find("TOOL_CALL: get_current_time")
assertNotNull(m)
assertEquals("get_current_time", m.groupValues[1])
assertTrue(m.groupValues[2].isEmpty())
}
@Test
fun markerNotFoundInPlainText() {
assertNull(TOOL_CALL_RE.find("Какое сейчас время?"))
}
// ---------- ToolSpec ----------
@Test
fun specArgNamesAndRequired() {
val spec = ToolSpec("media_search", "поиск", listOf("query", "seek_ms?"))
assertEquals(listOf("query", "seek_ms"), spec.argNames())
assertEquals(listOf("query"), spec.requiredArgs())
}
// ---------- toolJson (нативный LiteRT-канал) ----------
@Test
fun toolJsonHasFunctionFields() {
val json = toolJson(ToolSpec("media_search", "поиск трека", listOf("query", "seek_ms?")))
val obj = Json.parseToJsonElement(json) as JsonObject
assertEquals("media_search", (obj["name"] as JsonPrimitive).content)
val params = obj["parameters"] as JsonObject
val props = params["properties"] as JsonObject
assertTrue(props.containsKey("query"))
assertTrue(props.containsKey("seek_ms"))
val required = params["required"] as JsonArray
assertEquals(listOf("query"), required.map { (it as JsonPrimitive).content })
}
@Test
fun toolJsonWithoutArgsHasNoProperties() {
val json = toolJson(ToolSpec("get_current_time", "текущее время"))
val obj = Json.parseToJsonElement(json) as JsonObject
val params = obj["parameters"] as JsonObject
assertFalse(params.containsKey("properties"))
assertFalse(params.containsKey("required"))
}
}
+4 -3
View File
@@ -10,7 +10,7 @@ androidx-activity-compose = "1.10.1"
exoplayer = "2.17.1"
media3 = "1.6.1"
koog = "1.1.1"
litert-google = "3"
litert-google = "5"
[libraries]
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
@@ -25,9 +25,10 @@ exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
# LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm) на телефоне.
# КMP-обёртка pw.binom.litert:litert-google (git.binom.pw/subochev/litert-kmp):
# KMP-обёртка pw.binom.litert:litert-google (git.binom.pw/subochev/litert-kmp):
# универсальный контракт (LiteLlm/LiteRequest — RAW-инференс, владелец истории —
# потребитель) + google-бэкенд над официальным litertlm:0.16.1 (транзитивно).
# потребитель) + нативный тул-слой (LiteTool/LiteToolCall, tag 5) + google-бэкенд
# над официальным litertlm:0.16.1 (транзитивно).
litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert-google" }
# Универсальный контракт (LiteLlm/LiteRequest и др.) — типы импортируются
# приложением напрямую; в litert-google он runtime-only, поэтому явный.