Внедряем единый тул-канал для локального 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 } ).also { toolkit = it }
return Assistant( return Assistant(
chatDao = chatDao, chatDao = chatDao,
chat = { history, system -> llm.chat(history, system) }, chat = { history, toolSpecs, system -> llm.chatWithTools(history, toolSpecs, system) },
toolkit = tk, toolkit = tk,
// Нативный тул-канал LiteRT: только локальная модель видит схемы
// тул у движка; системный промпт тогда без текстовых TOOL_CALL-линий.
nativeTools = choice == LlmModelChoice.LOCAL,
// Локальной модели нужно время: инициализация движка ~17с на // Локальной модели нужно время: инициализация движка ~17с на
// CPU + генерация. Серверная укладывается в 30с, локальной // CPU + генерация. Серверная укладывается в 30с, локальной
// даём 5 минут (иначе withTimeoutOrNull режет генерацию). // даём 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 note = pendingNotes.remove(sessionId)
val activeSets = toolsets.activeNames(sessionId) val activeSets = toolsets.activeNames(sessionId)
val sb = StringBuilder() val sb = StringBuilder()
sb.append("Ты — ассистент Viewmate: краткий помощник на телефоне, связанный с очками Viewmate. ") sb.append("Ты — ассистент Viewmate: краткий помощник на телефоне, связанный с очками Viewmate. ")
.append("Делай то, что просит пользователь, отвечай кратко (1-3 предложения), по-русски.") .append("Делай то, что просит пользователь, отвечай кратко (1-3 предложения), по-русски.")
sb.append("\n\nДля тула ответь ровно одной строкой TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2 (без кавычек, без текста вокруг). ") if (nativeTools) {
.append("После получения результата тула ответь пользователю текстом.") sb.append("\n\nДля тула используй вызов инструмента из списка доступных тул (function calling). ")
.append("После получения результата тула ответь пользователю текстом.")
} else {
sb.append("\n\nДля тула ответь ровно одной строкой TOOL_CALL: имя_тула|арг1=знач1,арг2=знач2 (без кавычек, без текста вокруг). ")
.append("После получения результата тула ответь пользователю текстом.")
}
sb.append("\n\nЯдро:") sb.append("\n\nЯдро:")
CORE_LINES.forEach { sb.append('\n').append(it) } CORE_LINES.forEach { sb.append('\n').append(it) }
val catalog = toolsets.catalogLines() val catalog = toolsets.catalogLines()
@@ -85,6 +94,27 @@ class AgentToolkit(
return sb.toString() 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` (или «голое» значение для * Аргументы протокола `TOOL_CALL: имя|a=b,c=d` (или «голое» значение для
* одноаргументных тул) → карта. Прощающий: пары — через запятую/`&`, * одноаргументных тул) → карта. Логика — в топовой [parseToolCallArgs]
* лишние ключи не ошибка. Пустая строка/нет аргументов — пустая карта. * (общая с RemoteLlmClient), метод оставлен для API/тестов.
*/ */
fun parseArgs(raw: String?, expected: List<String>): Map<String, String> { fun parseArgs(raw: String?, expected: List<String>): Map<String, String> =
val s = raw?.trim() parseToolCallArgs(raw, expected)
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("[,&]")
// ---------- диспач вызовов ---------- // ---------- диспач вызовов ----------
@@ -9,19 +9,22 @@ import kotlinx.coroutines.withTimeoutOrNull
* (сериализация фраз — LLM-вызовы не пересекаются). * (сериализация фраз — LLM-вызовы не пересекаются).
* *
* Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если * Тулы: агент видит их в системном промпте (механика — [AgentToolkit], если
* передана, иначе старый [AgentTools]); если тула нужна — агент отвечает одной * передана, иначе старый [AgentTools]). Вызовы тул нормализованы на границе
* строкой `TOOL_CALL: имя|арг1=знач1,арг2=знач2` (аргументы — опционально). * [LlmClient]: локальный бэкенд декларует их нативным LiteRT-каналом,
* удалённый — текстовым маркером `TOOL_CALL: имя|арг1=знач1`; ассистент
* получает единый [LlmResult.toolCalls] и не знает, откуда приехали.
* Ассистент выполняет тул и возвращает результат как tool-сообщение в историю, * Ассистент выполняет тул и возвращает результат как tool-сообщение в историю,
* затем делает повторный вызов LLM. Невидимые системные тулы * затем делает повторный вызов LLM. Невидимые системные тулы
* (enable_toolset / disable_toolset) в историю НЕ пишутся — просто * (enable_toolset / disable_toolset) в историю НЕ пишутся — просто
* пересобирается системный промпт (состояние 3). Обычный текст (без маркера) * пересобирается системный промпт (состояние 3). Обычный текст (без тул-вызовов)
* возвращается пользователю как финальный ответ. * возвращается пользователю как финальный ответ.
*/ */
class Assistant( class Assistant(
private val chatDao: ChatDao, 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 toolkit: AgentToolkit? = null,
private val tools: List<AgentTool> = AgentTools.ALL, private val tools: List<AgentTool> = AgentTools.ALL,
private val nativeTools: Boolean = false,
private val timeoutMs: Long = 30_000, private val timeoutMs: Long = 30_000,
) { ) {
/** /**
@@ -50,7 +53,7 @@ class Assistant(
private suspend fun processInSession(sessionId: Long): String { private suspend fun processInSession(sessionId: Long): String {
val history = chatDao.messagesForSession(sessionId).takeLast(20) val history = chatDao.messagesForSession(sessionId).takeLast(20)
var answer = callLlm(history, sessionId) var result = callLlm(history, sessionId)
var lastToolResult: String? = null var lastToolResult: String? = null
var visibleCalls = 0 var visibleCalls = 0
var llmCalls = 1 var llmCalls = 1
@@ -58,31 +61,31 @@ class Assistant(
// зацикливания агента). Невидимые (silent) вызовы лимит не съедают, // зацикливания агента). Невидимые (silent) вызовы лимит не съедают,
// но общее число LLM-вызовов ограничено [MAX_LLM_CALLS]. // но общее число LLM-вызовов ограничено [MAX_LLM_CALLS].
while (true) { while (true) {
val parsed = parseToolCall(answer) val call = result.toolCalls.firstOrNull()
?: return answer // callLlm уже сохранил ASSISTANT-сообщение ?: return result.text // callLlm уже сохранил ASSISTANT-сообщение
val toolName = parsed.name val toolName = call.name
if (toolkit == null) { if (toolkit == null) {
// Наследственный путь (без механики тулсетов): простые тулы без аргументов. // Наследственный путь (без механики тулсетов): простые тулы без аргументов.
val tool = tools.firstOrNull { it.name == toolName } val tool = tools.firstOrNull { it.name == toolName }
if (tool == null) { if (tool == null) {
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer) chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = result.text)
return "Тул $toolName не найден" return "Тул $toolName не найден"
} }
lastToolResult = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" } lastToolResult = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" }
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = lastToolResult, toolCallId = toolName) chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = lastToolResult, toolCallId = toolName)
logTool(sessionId, toolName, lastToolResult) logTool(sessionId, toolName, lastToolResult)
if (++visibleCalls >= MAX_TOOL_CALLS) break if (++visibleCalls >= MAX_TOOL_CALLS) break
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId) result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
llmCalls++ llmCalls++
continue 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 -> { is AgentToolkit.Dispatch.Run -> {
lastToolResult = result.result lastToolResult = dispatch.result
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = result.result, toolCallId = toolName) chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = dispatch.result, toolCallId = toolName)
logTool(sessionId, toolName, result.result) logTool(sessionId, toolName, dispatch.result)
if (++visibleCalls >= MAX_TOOL_CALLS) break if (++visibleCalls >= MAX_TOOL_CALLS) break
answer = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId) result = callLlm(chatDao.messagesForSession(sessionId).takeLast(20), sessionId)
llmCalls++ llmCalls++
} }
@@ -92,29 +95,34 @@ class Assistant(
// (переход в состояние 3 — «Активные тулсеты»). // (переход в состояние 3 — «Активные тулсеты»).
logTool(sessionId, toolName, "невидимый вызов (пересборка промпта)") logTool(sessionId, toolName, "невидимый вызов (пересборка промпта)")
if (llmCalls++ >= MAX_LLM_CALLS) break 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 — отдаём последний результат тула как ответ, // LLM зациклилась на тул-вызовах — отдаём последний результат тула как
// чтобы пользователь получил данные, а не сырой маркер. // ответ, чтобы пользователь получил данные, а не сырой маркер.
val stillTool = parseToolCall(answer) val stillTool = result.toolCalls.firstOrNull()
return if (stillTool != null && lastToolResult != null) { return if (stillTool != null && lastToolResult != null) {
"TOOL_CALL ${stillTool.name} (повтор) — $lastToolResult" "TOOL_CALL ${stillTool.name} (повтор) — $lastToolResult"
} else { } else {
answer result.text
} }
} }
private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): String { private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): LlmResult {
val system = toolkit?.systemPrompt(sessionId) ?: legacySystemPrompt() val system = toolkit?.systemPrompt(sessionId, nativeTools) ?: legacySystemPrompt()
val answer = withTimeoutOrNull(timeoutMs) { chat(history, system) } val specs = if (toolkit != null) {
?: return "Ассистент не ответил (таймаут)" toolkit.toolSpecs(sessionId)
if (!isToolCall(answer)) { } else {
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer) 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) chatDao.touchSession(sessionId)
} }
return answer return result
} }
/** Системный промпт (наследственный путь, без [AgentToolkit]). */ /** Системный промпт (наследственный путь, без [AgentToolkit]). */
@@ -131,23 +139,6 @@ class Assistant(
/** Общий лимит LLM-вызовов на фразу (включая невидимые). */ /** Общий лимит LLM-вызовов на фразу (включая невидимые). */
const val MAX_LLM_CALLS = 8 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 символов). */ /** Случайное имя сессии (UUID-подобное, 8 символов). */
private fun randomTitle(): String = private fun randomTitle(): String =
"сессия-" + (1..8).map { "0123456789abcdef"[kotlin.random.Random.nextInt(16)] }.joinToString("") "сессия-" + (1..8).map { "0123456789abcdef"[kotlin.random.Random.nextInt(16)] }.joinToString("")
@@ -9,6 +9,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import pw.binom.viewmate.phone.log
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
@@ -26,6 +27,106 @@ interface LlmClient {
* [system] — системный промпт тулкита/настроек или null (не обязателен). * [system] — системный промпт тулкита/настроек или null (не обязателен).
*/ */
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String 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", "Продолжай.") if (dto.none { it.role == "user" }) dto += ChatMessageDto("user", "Продолжай.")
val body = json.encodeToString(ChatRequest.serializer(), ChatRequest(model = model, messages = dto)) 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() val request = Request.Builder()
.url("$baseUrl/v1/chat/completions") .url("$baseUrl/v1/chat/completions")
.header("Authorization", "Bearer $apiKey") .header("Authorization", "Bearer $apiKey")
@@ -115,11 +218,34 @@ class RemoteLlmClient(
val err = runCatching { json.decodeFromString<ChatResponse>(raw).error?.message }.getOrNull() val err = runCatching { json.decodeFromString<ChatResponse>(raw).error?.message }.getOrNull()
throw IllegalStateException("HTTP ${resp.code}: ${err ?: raw.take(200)}") throw IllegalStateException("HTTP ${resp.code}: ${err ?: raw.take(200)}")
} }
val text = runCatching { val reply = runCatching {
json.decodeFromString<ChatResponse>(raw).choices.firstOrNull()?.message?.content json.decodeFromString<ChatResponse>(raw).choices.firstOrNull()?.message?.content
}.getOrNull() }.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.LiteBackend
import pw.binom.litert.LiteConfig import pw.binom.litert.LiteConfig
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteExperimental import pw.binom.litert.LiteExperimental
import pw.binom.litert.LiteLlm import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteMessage 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 pw.binom.litert.google.googleLiteLlm
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout 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.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.io.File 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). */ /** Состояние локальной модели для UI настроек (StateFlow в PhoneApp). */
@@ -289,9 +348,9 @@ class GemmaModelDownloader(
* *
* Жизненный цикл: [LiteLlm] создаётся один раз (лениво, при первом [chat]), * Жизненный цикл: [LiteLlm] создаётся один раз (лениво, при первом [chat]),
* переиспользуется — модель заново не грузится. Каждый чатовый вызов — * переиспользуется — модель заново не грузится. Каждый чатовый вызов —
* новый RAW-инференс: [LiteRequest] несёт полный промпт (системная * новый RAW-инференс: [LiteConversationConfig] несёт полный промпт
* инструкция + вся история + входящее), движок не хранит состояние между * (системная инструкция + вся история + входящее) и схемы тул,
* вызовами. Ошибки — [IllegalStateException] с читаемым русским * движок не хранит состояние между вызовами. Ошибки — [IllegalStateException] с читаемым русским
* сообщением («локальная модель не скачана…») — [Assistant] покажет его * сообщением («локальная модель не скачана…») — [Assistant] покажет его
* пользователю, падение приложения исключено. * пользователю, падение приложения исключено.
* *
@@ -310,43 +369,87 @@ class LocalLlmClient(
/** Модель на месте (по имени файла; SHA-контроль — в [ensureLlm]). */ /** Модель на месте (по имени файла; SHA-контроль — в [ensureLlm]). */
fun modelDownloaded(): Boolean = modelFile.isFile fun modelDownloaded(): Boolean = modelFile.isFile
override suspend fun chat(messages: List<ChatMessage>, system: String?): String { override suspend fun chat(messages: List<ChatMessage>, system: String?): String =
log("llm", "chat: вызов (messages=${messages.size}, system=${system?.length ?: 0} симв)") 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() val llm = ensureLlm()
log("llm", "chat: движок готов, строю промпт") log("llm", "chat: движок готов, строю промпт")
val prompt = buildLocalLlmPrompt(messages, system) val prompt = buildLocalLlmPrompt(messages, system)
log("llm", "chat: промпт: system=${prompt.systemInstruction?.length ?: 0} симв, initial=${prompt.initialMessages.size}, incoming=${prompt.incoming.text.take(60)}") 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, systemInstruction = prompt.systemInstruction,
// RAW-режим: владелец истории — мы; каждый инференс — новый полный промпт // RAW-режим: владелец истории — мы; каждый инференс — новый полный промпт
messages = prompt.initialMessages + prompt.incoming, initialMessages = prompt.initialMessages + prompt.incoming,
tools = tools.map { SpecLiteTool(it) },
// Ручной режим: исполняют тулы не движок, а Assistant (см. KDoc метода)
automaticToolCalling = false,
) )
var text = "" var text = ""
var toolCalls = emptyList<LiteToolCall>()
var chunks = 0 var chunks = 0
var tokens = 0 var tokens = 0
val t0 = System.currentTimeMillis() val t0 = System.currentTimeMillis()
var firstTokenAt = 0L var firstTokenAt = 0L
var conversation: LiteConversation? = null
try { try {
withTimeout(120_000) { withTimeout(120_000) {
// либа сама закрывает беседу по завершении/отмене потока conversation = llm.createConversation(config)
llm.inferStream(request).collect { delta -> // Поток либы сам себя не завершает (callbackFlow живёт до отмены):
chunks++ // остановка — по терминальной дельте isDone (см. KDoc LiteDelta).
val chunkText = delta.text conversation.sendStream(prompt.incoming.text)
text += chunkText .onEach { delta ->
if (chunkText.isNotEmpty()) { chunks++
if (firstTokenAt == 0L) firstTokenAt = System.currentTimeMillis() val chunkText = delta.text
tokens += 1 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) { .takeWhile { !it.isDone }
log("llm", "chat: чанк #$chunks: ${chunkText.take(50)}") .collect { }
}
}
} }
val totalMs = System.currentTimeMillis() - t0 val totalMs = System.currentTimeMillis() - t0
val ttftMs = if (firstTokenAt > 0L) firstTokenAt - t0 else totalMs val ttftMs = if (firstTokenAt > 0L) firstTokenAt - t0 else totalMs
val tokPerSec = if (totalMs > 0) (tokens * 1000.0 / totalMs) else 0.0 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}мс)") log("llm", "chat: готово, чанков=$chunks, симв=${text.length}, TTFB=${ttftMs}мс, ~${String.format(java.util.Locale.US, "%.1f", tokPerSec)} чанк/с, тул-вызовов=${toolCalls.size} (всего ${totalMs}мс)")
return text.trim() 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) { } catch (e: CancellationException) {
log("llm", "chat: отменено") log("llm", "chat: отменено")
throw e throw e
@@ -354,6 +457,10 @@ class LocalLlmClient(
log("llm", "chat: EXC class=${e::class.simpleName} msgLen=${e.message?.length}") log("llm", "chat: EXC class=${e::class.simpleName} msgLen=${e.message?.length}")
log("llm", "chat: EXC msg=${sanitize(e.message)}") log("llm", "chat: EXC msg=${sanitize(e.message)}")
throw IllegalStateException("Локальная модель не ответила: ${e.message}", e) 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" exoplayer = "2.17.1"
media3 = "1.6.1" media3 = "1.6.1"
koog = "1.1.1" koog = "1.1.1"
litert-google = "3" litert-google = "5"
[libraries] [libraries]
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } 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" } media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
# LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm) на телефоне. # 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-инференс, владелец истории — # универсальный контракт (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" } litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert-google" }
# Универсальный контракт (LiteLlm/LiteRequest и др.) — типы импортируются # Универсальный контракт (LiteLlm/LiteRequest и др.) — типы импортируются
# приложением напрямую; в litert-google он runtime-only, поэтому явный. # приложением напрямую; в litert-google он runtime-only, поэтому явный.