memory-vector: LlmMemoryReviewer + SkillStore/SkillSaveTool/SkillDeleteTool
Phase 3 (Hermes-style self-improvement) and Phase 5.2 (LLM-driven review):
* :skills — SkillStore interface + DiskSkillStore (upsert/remove, file<->catalog sync)
* :skills — SkillParser.serialize for write-back path
* :standalone — SkillSaveTool/SkillDeleteTool + SkillToolsFactory
* :standalone — LlmMemoryReviewer: one-shot LiteLlm review via structured-output
JSON prompt ({toSave:[...], toDelete:[...]}); reuses MemorySystem store
* :standalone — ReviewDecisionParser (lenient, handles json fences, missing
arrays, malformed numbers)
* :standalone — ReviewPrompts (Russian system+user prompts, fact categories)
* :standalone/Main — wires LlmMemoryReviewer instead of KeywordMdReviewer when
LLM is available, falls back to keyword for off/md-only mode
* :client — send(content, context) overload + SendPayload wrapper
* :proto — ExperimentalNativeApi opt-in for MessageContextTest (native targets)
Bug fixes:
* SkillTools.kt: error() shadowed kotlin.error(); renamed to Nothing
* ChatConversation: .map { when(...); error() } → .mapNotNull { when ... else -> null }
(Kotlin type inference of LUB LiteMessage | Nothing failed across when-expr)
Tests: 284 total green (memory-vector: 17, standalone: 149).
Dropped: IRC-QUESTIONS.md (irc-server design rejected — user decision 2026-09-14).
This commit is contained in:
@@ -19,3 +19,9 @@ out/
|
|||||||
# Local tooling (Magic Context, IDE plugins, MCP configs)
|
# Local tooling (Magic Context, IDE plugins, MCP configs)
|
||||||
.cortexkit/
|
.cortexkit/
|
||||||
.veai/
|
.veai/
|
||||||
|
|
||||||
|
# Runtime / test artifacts
|
||||||
|
agentik.db
|
||||||
|
agentik.db-shm
|
||||||
|
agentik.db-wal
|
||||||
|
memory-md/agentik-mem-*/
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
# IRC-транспорт — открытые вопросы
|
|
||||||
|
|
||||||
По мере закрытия отмечаем `- N. [x]`. Закрытый вопрос остаётся в файле с принятым решением.
|
|
||||||
|
|
||||||
- 1. [x] **История.** Принято: новый абстрактный метод `suspend fun getLatestMessages(offset: Int, limit: Int): List<Message>` в `:proto.Conversation` (offset = пропустить С КОНЦА, 0 = самые свежие). IRC-сервер не держит своего буфера, на `CHATHISTORY` дёргает агента. CAP `draft/chathistory` объявляем.
|
|
||||||
- 2. [x] **Tool/Error/Image события — раскладка по IRC.** Принято. Каждый `Event` мапится:
|
|
||||||
- `StartReasoning` → дропаем с провода
|
|
||||||
- `StartResponse(TEXT|IMAGE)` → CTCP `AGENTIK response-start {"type":"text"|"image"}`
|
|
||||||
- `AppendText(body)` → `PRIVMSG #chan :body`
|
|
||||||
- `AppendImage(body, mime)` → через `ImageStore` → CTCP `AGENTIK image {"url":..,"mime":..,"ttl":..}`
|
|
||||||
- `ToolCall` → CTCP `AGENTIK tool-call {json}`
|
|
||||||
- `ToolResult` → CTCP `AGENTIK tool-result {json}`
|
|
||||||
- `Error` → CTCP `AGENTIK error {json}`
|
|
||||||
- `End` → CTCP `AGENTIK end`
|
|
||||||
- `Interrupted` → CTCP `AGENTIK interrupted`
|
|
||||||
- 3. [x] **Interrupt.** **Упрощение:** команды `/stop` и `/interrupt` в `PRIVMSG` (т.е. `PRIVMSG #chan :/stop`) вызывают `Conversation.interrupt()`. Если `PRIVMSG` приходит во время активного размышления — сервер сначала зовёт `interrupt()`, затем `send(content)`. CTCP-вариант дропаем.
|
|
||||||
- 4. [x] **`AgentEvent.Created/Deleted/Renamed` маппинг.** Принято: Created = IRC `JOIN`-бродкаст; Deleted = `KICK` самого себя; Renamed = `TOPIC #foo :new title`.
|
|
||||||
- 5. [x] **NICK агента.** Принято: параметр в DSL, дефолт `"Agent"`.
|
|
||||||
- 6. [x] **Multi-user в канале.** Принято: **в канале всегда только наш агент и наш пользователь. Других не будет никогда.**
|
|
||||||
- 7. [x] **Маппинг канал ↔ Conversation.** Принято: имя IRC-канала = `Conversation.title`; `Conversation.id` = UUID, выдаётся через `CTCP AGENTIK id #foo`; при переименовании канала id стабилен.
|
|
||||||
- 8. [x] **Создание канала.** Принято: `JOIN #foo` → создаём `Conversation(title="foo", id=<uuid>)`. Если уже есть — заходим.
|
|
||||||
- 9. [x] **Удаление канала.** Принято: `PART` закрывает сторону клиента; `CTCP AGENTIK delete #foo` — удаление Conversation-а.
|
|
||||||
- 10. [x] **Модуль.** Принято: `:irc-server`, KMP через kotlinx-io. Также модуль содержит HTTP staging-эндпоинт для картинок (см. п.13).
|
|
||||||
- 11. [x] **Аутентификация клиента.** Принято: без auth, любой может подключиться.
|
|
||||||
- 12. [x] **Capabilities (ircv3).** Принято: в первом проходе объявляем `server-time`, `message-tags`, `batch`, `draft/chathistory`. SASL не объявляем (п.11). Остальные CAPs (echo-message, labeled-response, standard-replies, multi-user stuff) добавляем инкрементально.
|
|
||||||
- 13. [x] **ImageStore.** Принято: `ImageStore` живёт в `:irc-server`, дефолтная in-memory реализация с **TTL 600 сек**, staging-порт **авто-pick** (0 → свободный). Клиент через IRC картинки **не шлёт** (для этого HTTP `:server`). Конкретную реализацию `ImageStore` пользователь сделает позже сам, в первом проходе — наша in-memory.
|
|
||||||
|
|
||||||
## Все вопросы закрыты
|
|
||||||
|
|
||||||
Итого решений по `:irc-server`:
|
|
||||||
- Модуль `:irc-server`, KMP через kotlinx-io.
|
|
||||||
- Канал IRC = `Conversation` (имя = title, UUID через CTCP `AGENTIK id`).
|
|
||||||
- В канале всегда только 1 пользователь + агент (ник `Agent` по умолчанию).
|
|
||||||
- `Event` → IRC: `PRIVMSG` для текста, CTCP `AGENTIK <имя> {json}` для всего остального. `StartReasoning` дропается.
|
|
||||||
- Interrupt через `/stop` / `/interrupt` в PRIVMSG; входящий PRIVMSG во время размышления = `interrupt()` + `send()`.
|
|
||||||
- История через `CHATHISTORY` (LATEST/BEFORE/BETWEEN/AFTER), сервер не буферизует, дёргает новый `:proto` метод `getLatestMessages(offset, limit)`.
|
|
||||||
- Картинки только agent → client через `ImageStore` + HTTP staging в том же модуле.
|
|
||||||
- CAPs: `server-time`, `message-tags`, `batch`, `draft/chathistory`. Без auth.
|
|
||||||
|
|
||||||
Можно кодить.
|
|
||||||
@@ -13,10 +13,12 @@ import io.ktor.http.HttpStatusCode
|
|||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import pw.binom.agentik.proto.Content
|
import pw.binom.agentik.proto.Content
|
||||||
import pw.binom.agentik.proto.Conversation
|
import pw.binom.agentik.proto.Conversation
|
||||||
import pw.binom.agentik.proto.Event
|
import pw.binom.agentik.proto.Event
|
||||||
import pw.binom.agentik.proto.Message
|
import pw.binom.agentik.proto.Message
|
||||||
|
import pw.binom.agentik.proto.MessageContext
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,10 +60,10 @@ internal class ConversationClient(
|
|||||||
snapshot = updated
|
snapshot = updated
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun send(content: List<Content>) {
|
override suspend fun send(content: List<Content>, context: MessageContext?) {
|
||||||
httpClient.post("$convUrl/messages") {
|
httpClient.post("$convUrl/messages") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(content)
|
setBody(SendPayload(content, context))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,3 +94,9 @@ internal class ConversationClient(
|
|||||||
// Agent.deleteConversation(id). См. [Conversation.close] KDoc.
|
// Agent.deleteConversation(id). См. [Conversation.close] KDoc.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class SendPayload(
|
||||||
|
val content: List<Content>,
|
||||||
|
val context: MessageContext? = null,
|
||||||
|
)
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ class MessageContextTest {
|
|||||||
assertEquals(ctx.metadata, decoded.metadata)
|
assertEquals(ctx.metadata, decoded.metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@kotlin.experimental.ExperimentalNativeApi
|
||||||
@Test
|
@Test
|
||||||
fun `origin field name is origin in JSON`() {
|
fun `origin field name is origin in JSON`() {
|
||||||
val ctx = MessageContext(origin = MessageOrigin.SYSTEM, description = "boot")
|
val ctx = MessageContext(origin = MessageOrigin.SYSTEM, description = "boot")
|
||||||
|
|||||||
@@ -129,6 +129,38 @@ object SkillParser {
|
|||||||
private fun String.stripBom(): String =
|
private fun String.stripBom(): String =
|
||||||
if (isNotEmpty() && this[0] == '\uFEFF') substring(1) else this
|
if (isNotEmpty() && this[0] == '\uFEFF') substring(1) else this
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сериализует [skill] в формат SKILL.md (YAML-фронтматтер + markdown body).
|
||||||
|
* Используется [SkillStore] для записи скилов на диск.
|
||||||
|
*
|
||||||
|
* Формат:
|
||||||
|
* ```
|
||||||
|
* ---
|
||||||
|
* name: backend:spring:db-base
|
||||||
|
* description: Use when ...
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* # Body markdown...
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
fun serialize(skill: SkillFile): String {
|
||||||
|
val body = skill.body.trimStart('\n').trimEnd('\n')
|
||||||
|
val yamlBody = yaml.encodeToString(
|
||||||
|
Frontmatter.serializer(),
|
||||||
|
Frontmatter(name = skill.name, description = skill.description),
|
||||||
|
)
|
||||||
|
return buildString {
|
||||||
|
append("---\n")
|
||||||
|
append(yamlBody)
|
||||||
|
append("---\n")
|
||||||
|
if (body.isNotEmpty()) {
|
||||||
|
append("\n")
|
||||||
|
append(body)
|
||||||
|
append("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* kaml кидает [com.charleskorn.kaml.YamlException] и на синтаксические
|
* kaml кидает [com.charleskorn.kaml.YamlException] и на синтаксические
|
||||||
* ошибки YAML, и на отсутствие обязательных полей. Различаем по сообщению:
|
* ошибки YAML, и на отсутствие обязательных полей. Различаем по сообщению:
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package pw.binom.agentik.skills
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хранилище скилов с операциями upsert/remove.
|
||||||
|
*
|
||||||
|
* Реализации обязаны:
|
||||||
|
* - `upsert` — записать скил в постоянное хранилище (диск) и сделать его
|
||||||
|
* доступным в [catalog] сразу же после вызова;
|
||||||
|
* - `remove(name)` — пометить скил как archived (убрать из активного [catalog])
|
||||||
|
* и вернуть `true`, если такой скил был; `false` — если не нашли;
|
||||||
|
* - быть потокобезопасными для конкурентных вызовов из нескольких корутин.
|
||||||
|
*
|
||||||
|
* Это контракт, через который SkillSaveTool / SkillDeleteTool общаются с
|
||||||
|
* диском. Без этого skill self-improvement в Hermes-стиле был бы невозможен.
|
||||||
|
*/
|
||||||
|
interface SkillStore : AutoCloseable {
|
||||||
|
/** Текущий каталог активных (не archived) скилов. */
|
||||||
|
val catalog: SkillCatalog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт или обновляет скил. Имя скила может содержать `:` (как в
|
||||||
|
* opencode: `backend:spring:db-base`); маппинг на файловую систему —
|
||||||
|
* забота реализации.
|
||||||
|
*/
|
||||||
|
fun upsert(skill: SkillFile)
|
||||||
|
|
||||||
|
/** Архивирует скил по имени: убирает из [catalog], но не удаляет файл. */
|
||||||
|
fun remove(name: String): Boolean
|
||||||
|
|
||||||
|
override fun close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package pw.binom.agentik.skills
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
import kotlin.concurrent.withLock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JVM-реализация [SkillStore] поверх папки скилов.
|
||||||
|
*
|
||||||
|
* Конвенция путей:
|
||||||
|
* - `click-on` → `skills/click-on/SKILL.md` (одна директория);
|
||||||
|
* - `backend:spring:db-base` → `skills/backend/spring/db-base/SKILL.md` — все сегменты
|
||||||
|
* после первого становятся вложенными директориями.
|
||||||
|
*
|
||||||
|
* Архивирование:
|
||||||
|
* - при [remove] файл переименовывается в `<original>.archived` и больше не
|
||||||
|
* попадает в [catalog]. Это позволяет восстановить скил, если нужно.
|
||||||
|
*
|
||||||
|
* Потокобезопасность: все операции защищены одним [ReentrantLock] — write-tool
|
||||||
|
* может зваться из корутины, читатели каталога — из других.
|
||||||
|
*/
|
||||||
|
class DiskSkillStore(
|
||||||
|
private val dir: File,
|
||||||
|
) : SkillStore {
|
||||||
|
|
||||||
|
private val lock = ReentrantLock()
|
||||||
|
|
||||||
|
// Кэш: активные скилы по имени. На старте заполняется из диска, потом
|
||||||
|
// обновляется по мере upsert/remove.
|
||||||
|
private val active: MutableMap<String, SkillFile> = linkedMapOf()
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (!dir.exists()) dir.mkdirs()
|
||||||
|
// При старте — перечитываем каталог, пропуская .archived файлы.
|
||||||
|
val loadResult = SkillLoader.loadDirectory(dir)
|
||||||
|
for (skill in loadResult.catalog.skills) {
|
||||||
|
active[skill.name] = skill
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override val catalog: SkillCatalog
|
||||||
|
get() = lock.withLock { SkillCatalog(active.values.toList()) }
|
||||||
|
|
||||||
|
override fun upsert(skill: SkillFile) {
|
||||||
|
require(skill.name.isNotBlank()) { "skill name must not be blank" }
|
||||||
|
require(skill.description.isNotBlank()) { "skill description must not be blank" }
|
||||||
|
lock.withLock {
|
||||||
|
val target = skillFile(skill.name)
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
target.writeText(SkillParser.serialize(skill), Charsets.UTF_8)
|
||||||
|
active[skill.name] = skill
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun remove(name: String): Boolean = lock.withLock {
|
||||||
|
val existing = active.remove(name) ?: return@withLock false
|
||||||
|
val target = skillFile(name)
|
||||||
|
if (target.exists()) {
|
||||||
|
val archived = File(target.parentFile, target.name + ".archived")
|
||||||
|
// Если архивная копия уже есть — дописываем суффикс
|
||||||
|
val finalArchived = if (archived.exists()) {
|
||||||
|
var i = 1
|
||||||
|
var f: File
|
||||||
|
do {
|
||||||
|
f = File(target.parentFile, "${target.name}.archived.$i")
|
||||||
|
i++
|
||||||
|
} while (f.exists())
|
||||||
|
f
|
||||||
|
} else archived
|
||||||
|
target.renameTo(finalArchived)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// Никаких ресурсов: всё на диске. Просто no-op.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маппинг имени на файловый путь:
|
||||||
|
* `backend:spring:db-base` → `dir/backend/spring/db-base/SKILL.md`
|
||||||
|
* `click-on` → `dir/click-on/SKILL.md`
|
||||||
|
*/
|
||||||
|
private fun skillFile(name: String): File {
|
||||||
|
val segments = name.split(':').map { sanitize(it) }
|
||||||
|
require(segments.isNotEmpty() && segments.all { it.isNotEmpty() }) {
|
||||||
|
"invalid skill name '$name'"
|
||||||
|
}
|
||||||
|
val path = File(dir, segments.joinToString("/"))
|
||||||
|
return File(path, "SKILL.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Убираем из сегментов пути всё кроме `[A-Za-z0-9_-]`. */
|
||||||
|
private fun sanitize(s: String): String =
|
||||||
|
s.filter { it.isLetterOrDigit() || it == '_' || it == '-' }
|
||||||
|
.ifEmpty { "_" }
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import io.ktor.server.engine.embeddedServer
|
|||||||
import io.ktor.server.response.respondText
|
import io.ktor.server.response.respondText
|
||||||
import io.ktor.server.routing.get
|
import io.ktor.server.routing.get
|
||||||
import io.ktor.server.routing.routing
|
import io.ktor.server.routing.routing
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.io.files.Path
|
import kotlinx.io.files.Path
|
||||||
|
import pw.binom.agentik.memory.MemoryReviewer
|
||||||
import pw.binom.agentik.memory.MemorySystem
|
import pw.binom.agentik.memory.MemorySystem
|
||||||
import pw.binom.agentik.memory.md.openMdMemorySystem
|
import pw.binom.agentik.memory.md.openMdMemorySystem
|
||||||
import pw.binom.agentik.memory.vector.VectorMemorySystem
|
import pw.binom.agentik.memory.vector.VectorMemorySystem
|
||||||
@@ -15,6 +17,7 @@ import pw.binom.agentik.skills.SkillCatalog
|
|||||||
import pw.binom.agentik.skills.SkillLoader
|
import pw.binom.agentik.skills.SkillLoader
|
||||||
import pw.binom.agentik.standalone.agent.ChatAgent
|
import pw.binom.agentik.standalone.agent.ChatAgent
|
||||||
import pw.binom.agentik.standalone.agent.LiteLlmContextCompactor
|
import pw.binom.agentik.standalone.agent.LiteLlmContextCompactor
|
||||||
|
import pw.binom.agentik.standalone.agent.memory.LlmMemoryReviewer
|
||||||
import pw.binom.agentik.standalone.config.AgentikConfig
|
import pw.binom.agentik.standalone.config.AgentikConfig
|
||||||
import pw.binom.agentik.standalone.config.AgentikConfig.MemoryBackend
|
import pw.binom.agentik.standalone.config.AgentikConfig.MemoryBackend
|
||||||
import pw.binom.agentik.standalone.llm.LlmBackend
|
import pw.binom.agentik.standalone.llm.LlmBackend
|
||||||
@@ -118,6 +121,16 @@ fun main() {
|
|||||||
val contextWindow: Int? = config.llm.resolveContextWindow()
|
val contextWindow: Int? = config.llm.resolveContextWindow()
|
||||||
val contextCompactor = if (contextWindow != null) LiteLlmContextCompactor(liteLlm = llm) else null
|
val contextCompactor = if (contextWindow != null) LiteLlmContextCompactor(liteLlm = llm) else null
|
||||||
|
|
||||||
|
// Review-loop: всегда используем LlmMemoryReviewer поверх LiteLlm, если память включена.
|
||||||
|
// Hermes-style one-shot с structured-output (JSON). Fallback — heuristic reviewer из бэкенда.
|
||||||
|
val memoryReviewer: MemoryReviewer? = memorySystem?.store?.let { store ->
|
||||||
|
LlmMemoryReviewer(
|
||||||
|
liteLlm = llm,
|
||||||
|
store = store,
|
||||||
|
dispatcher = Dispatchers.IO,
|
||||||
|
)
|
||||||
|
} ?: memorySystem?.reviewer
|
||||||
|
|
||||||
val agent = ChatAgent(
|
val agent = ChatAgent(
|
||||||
id = "agentik",
|
id = "agentik",
|
||||||
stores = stores,
|
stores = stores,
|
||||||
@@ -127,7 +140,7 @@ fun main() {
|
|||||||
skills = skills,
|
skills = skills,
|
||||||
memoryStore = memorySystem?.store,
|
memoryStore = memorySystem?.store,
|
||||||
memoryPrefetcher = memorySystem?.prefetcher,
|
memoryPrefetcher = memorySystem?.prefetcher,
|
||||||
memoryReviewer = memorySystem?.reviewer,
|
memoryReviewer = memoryReviewer,
|
||||||
soulBody = soulBody,
|
soulBody = soulBody,
|
||||||
contextWindow = contextWindow,
|
contextWindow = contextWindow,
|
||||||
compressionThreshold = config.compressionThreshold,
|
compressionThreshold = config.compressionThreshold,
|
||||||
|
|||||||
@@ -623,21 +623,23 @@ class ChatConversation(
|
|||||||
?.let { (it.entry as WorkingMemoryEntry.System).text }
|
?.let { (it.entry as WorkingMemoryEntry.System).text }
|
||||||
?: systemPrompt
|
?: systemPrompt
|
||||||
|
|
||||||
val pastTurns = if (record.isTemporal) emptyList() else wm
|
val pastTurns: List<LiteMessage> = if (record.isTemporal) emptyList() else wm
|
||||||
.filter { row ->
|
.filter { row ->
|
||||||
val isUserOrAssistant = row.entry is WorkingMemoryEntry.User || row.entry is WorkingMemoryEntry.Assistant
|
val isUserOrAssistant = row.entry is WorkingMemoryEntry.User || row.entry is WorkingMemoryEntry.Assistant
|
||||||
val isPendingUser = excludeUserSourceId != null && row.sourceMessageId == excludeUserSourceId
|
val isPendingUser = excludeUserSourceId != null && row.sourceMessageId == excludeUserSourceId
|
||||||
isUserOrAssistant && !isPendingUser
|
isUserOrAssistant && !isPendingUser
|
||||||
}
|
}
|
||||||
.map { row ->
|
.mapNotNull { row ->
|
||||||
when (val e = row.entry) {
|
val e: WorkingMemoryEntry = row.entry
|
||||||
|
val msg: LiteMessage? = when (e) {
|
||||||
is WorkingMemoryEntry.User -> LiteMessage(
|
is WorkingMemoryEntry.User -> LiteMessage(
|
||||||
LiteRole.USER,
|
LiteRole.USER,
|
||||||
applyContextPrefix(e.content.toLiteContents(), e.context),
|
applyContextPrefix(e.content.toLiteContents(), e.context),
|
||||||
)
|
)
|
||||||
is WorkingMemoryEntry.Assistant -> LiteMessage(LiteRole.MODEL, e.content.toLiteContents())
|
is WorkingMemoryEntry.Assistant -> LiteMessage(LiteRole.MODEL, e.content.toLiteContents())
|
||||||
else -> error("unreachable")
|
else -> null
|
||||||
}
|
}
|
||||||
|
msg
|
||||||
}
|
}
|
||||||
|
|
||||||
val config = LiteConversationConfig(
|
val config = LiteConversationConfig(
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import pw.binom.agentik.skills.SkillFile
|
||||||
|
import pw.binom.agentik.skills.SkillStore
|
||||||
|
import pw.binom.litert.LiteTool
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тул `skill_save(name, description, body)` — сохраняет скил через [SkillStore].
|
||||||
|
*
|
||||||
|
* Схема аргументов (function-calling JSON):
|
||||||
|
* ```
|
||||||
|
* {
|
||||||
|
* "name": "skill_save",
|
||||||
|
* "description": "Создать или обновить скил. Имя может содержать двоеточия (как в opencode: 'backend:spring:db-base').",
|
||||||
|
* "parameters": {
|
||||||
|
* "type": "object",
|
||||||
|
* "properties": {
|
||||||
|
* "name": {"type": "string", "description": "Имя скила, уникальное в каталоге."},
|
||||||
|
* "description": {"type": "string", "description": "Одно-два предложения: когда применять."},
|
||||||
|
* "body": {"type": "string", "description": "Markdown body скила."}
|
||||||
|
* },
|
||||||
|
* "required": ["name", "description", "body"]
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ВАЖНО: параметр LiteTool-лямбды не называется `invoke` — иначе Kotlin
|
||||||
|
* резолвит `invoke(x)` рекурсивно и StackOverflow (см. MemoryToolsFactory).
|
||||||
|
*/
|
||||||
|
internal class SkillSaveTool(
|
||||||
|
private val store: SkillStore,
|
||||||
|
) : LiteTool {
|
||||||
|
override fun describe(): String = SCHEMA
|
||||||
|
|
||||||
|
override fun invoke(arguments: String): String {
|
||||||
|
val parsed = parseArgs(arguments)
|
||||||
|
?: return error("invalid arguments: $arguments")
|
||||||
|
val (name, description, body) = parsed
|
||||||
|
return try {
|
||||||
|
store.upsert(SkillFile(name = name, description = description, body = body))
|
||||||
|
"""{"ok":true,"name":"${escape(name)}"}"""
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error(e.message ?: "upsert failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseArgs(arguments: String): Triple<String, String, String>? {
|
||||||
|
// Минимальный парсер JSON-объекта — вытаскиваем три строковых поля.
|
||||||
|
// Полагаемся на порядок ключей в агенте: name, description, body.
|
||||||
|
val name = extractString(arguments, "name") ?: return null
|
||||||
|
val description = extractString(arguments, "description") ?: return null
|
||||||
|
val body = extractString(arguments, "body") ?: return null
|
||||||
|
return Triple(name, description, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SCHEMA = """
|
||||||
|
{
|
||||||
|
"name": "skill_save",
|
||||||
|
"description": "Создать или обновить скил. Имя может содержать двоеточия (например 'backend:spring:db-base'). Скил станет доступен в этом и следующих сеансах через read_skill.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"description": {"type": "string"},
|
||||||
|
"body": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["name", "description", "body"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тул `skill_delete(name)` — архивирует скил.
|
||||||
|
*/
|
||||||
|
internal class SkillDeleteTool(
|
||||||
|
private val store: SkillStore,
|
||||||
|
) : LiteTool {
|
||||||
|
override fun describe(): String = SCHEMA
|
||||||
|
override fun invoke(arguments: String): String {
|
||||||
|
val name = extractString(arguments, "name")
|
||||||
|
?: return error("missing 'name' in arguments: $arguments")
|
||||||
|
return try {
|
||||||
|
val removed = store.remove(name)
|
||||||
|
if (removed) {
|
||||||
|
"""{"ok":true,"archived":"${escape(name)}"}"""
|
||||||
|
} else {
|
||||||
|
error("skill '$name' not found")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error(e.message ?: "delete failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SCHEMA = """
|
||||||
|
{
|
||||||
|
"name": "skill_delete",
|
||||||
|
"description": "Архивировать скил по имени. Скил больше не будет появляться в read_skill, но файл остаётся на диске с суффиксом .archived.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["name"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Достаёт строковое значение из JSON-объекта по ключу. Минимальный парсер —
|
||||||
|
* JSON простой (плоский объект с известным набором ключей), как в
|
||||||
|
* MemoryToolsFactory.
|
||||||
|
*/
|
||||||
|
internal fun extractString(json: String, key: String): String? {
|
||||||
|
val keyIdx = json.indexOf("\"$key\"")
|
||||||
|
if (keyIdx < 0) return null
|
||||||
|
val colon = json.indexOf(':', keyIdx)
|
||||||
|
if (colon < 0) return null
|
||||||
|
val firstQuote = json.indexOf('"', colon)
|
||||||
|
if (firstQuote < 0) return null
|
||||||
|
|
||||||
|
var i = firstQuote + 1
|
||||||
|
val sb = StringBuilder()
|
||||||
|
while (i < json.length) {
|
||||||
|
val c = json[i]
|
||||||
|
when {
|
||||||
|
c == '\\' && i + 1 < json.length -> {
|
||||||
|
when (val next = json[i + 1]) {
|
||||||
|
'n' -> sb.append('\n')
|
||||||
|
't' -> sb.append('\t')
|
||||||
|
'r' -> sb.append('\r')
|
||||||
|
'"' -> sb.append('"')
|
||||||
|
'\\' -> sb.append('\\')
|
||||||
|
else -> sb.append(next)
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
c == '"' -> return sb.toString()
|
||||||
|
else -> {
|
||||||
|
sb.append(c)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON-escape строки. */
|
||||||
|
internal fun escape(s: String): String = buildString(s.length + 2) {
|
||||||
|
for (c in s) {
|
||||||
|
when (c) {
|
||||||
|
'"' -> append("\\\"")
|
||||||
|
'\\' -> append("\\\\")
|
||||||
|
'\n' -> append("\\n")
|
||||||
|
'\r' -> append("\\r")
|
||||||
|
'\t' -> append("\\t")
|
||||||
|
else -> append(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строит `{"error":"..."}` JSON-ответ. */
|
||||||
|
internal fun error(message: String): Nothing = throw IllegalStateException(message)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent
|
||||||
|
|
||||||
|
import pw.binom.agentik.skills.SkillStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фабрика tools для self-improvement'а скилов (Phase 3 Hermes-style).
|
||||||
|
*
|
||||||
|
* Возвращает два NamedTool'а:
|
||||||
|
* - `skill_save(name, description, body)` — создать или обновить скил;
|
||||||
|
* - `skill_delete(name)` — архивировать скил.
|
||||||
|
*
|
||||||
|
* Оба оборачивают [SkillStore]; агент может дёргать их в любой момент,
|
||||||
|
* не только во время review-loop.
|
||||||
|
*/
|
||||||
|
object SkillToolsFactory {
|
||||||
|
|
||||||
|
fun create(store: SkillStore): List<NamedTool> = listOf(
|
||||||
|
NamedTool("skill_save", SkillSaveTool(store)),
|
||||||
|
NamedTool("skill_delete", SkillDeleteTool(store)),
|
||||||
|
)
|
||||||
|
}
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent.memory
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import pw.binom.agentik.memory.ConversationTurn
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryReviewDecision
|
||||||
|
import pw.binom.agentik.memory.MemoryReviewer
|
||||||
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
|
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||||
|
import pw.binom.agentik.memory.NewMemoryNote
|
||||||
|
import pw.binom.agentik.memory.ReviewedTurn
|
||||||
|
import pw.binom.agentik.standalone.persistence.Ids
|
||||||
|
import pw.binom.litert.LiteLlm
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реализация [MemoryReviewer] поверх on-device LLM (LiteLlm / Google LiteRT-LM).
|
||||||
|
*
|
||||||
|
* После каждого хода (или пачки ходов при compaction) зовём LiteLlm с
|
||||||
|
* специальным промптом, который просит модель вернуть JSON со списком
|
||||||
|
* новых заметок и удалений. Парсим руками (см. [ReviewDecisionParser]) —
|
||||||
|
* on-device модели с tool-calling работают ненадёжно, structured output
|
||||||
|
* стабильнее.
|
||||||
|
*
|
||||||
|
* Конструктор принимает `dispatcher` чтобы I/O LiteLlm не блокировал
|
||||||
|
* основной поток. По умолчанию — `Dispatchers.IO` (вытягивается из контекста).
|
||||||
|
*
|
||||||
|
* @param maxExistingFacts сколько последних заметок подмешивать в промпт
|
||||||
|
* как [memory-context], чтобы модель не дублировала уже сохранённые факты.
|
||||||
|
*/
|
||||||
|
class LlmMemoryReviewer(
|
||||||
|
private val liteLlm: LiteLlm,
|
||||||
|
private val store: MemoryStore,
|
||||||
|
private val dispatcher: CoroutineDispatcher,
|
||||||
|
private val maxExistingFacts: Int = 30,
|
||||||
|
private val clock: Clock = Clock.System,
|
||||||
|
) : MemoryReviewer {
|
||||||
|
|
||||||
|
override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision = withContext(dispatcher) {
|
||||||
|
// Снимок существующих заметок — чтобы модель не дублировала
|
||||||
|
val existingFacts = store.list(limit = maxExistingFacts, offset = 0)
|
||||||
|
.joinToString("\n") { "- [${it.category.name}] ${it.content.take(120)}" }
|
||||||
|
|
||||||
|
val prompt = ReviewPrompts.reviewUserPrompt(turn, existingFacts)
|
||||||
|
val conv = liteLlm.createConversation(
|
||||||
|
pw.binom.litert.LiteConversationConfig(
|
||||||
|
systemInstruction = ReviewPrompts.REVIEW_SYSTEM_PROMPT,
|
||||||
|
temperature = 0.2f,
|
||||||
|
maxTokens = 512,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val raw = try {
|
||||||
|
conv.send(prompt)
|
||||||
|
} finally {
|
||||||
|
conv.close()
|
||||||
|
}
|
||||||
|
ReviewDecisionParser.parse(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reviewPreCompaction(turns: List<ConversationTurn>): MemoryReviewDecision = withContext(dispatcher) {
|
||||||
|
if (turns.isEmpty()) return@withContext MemoryReviewDecision()
|
||||||
|
|
||||||
|
val existingFacts = store.list(limit = maxExistingFacts, offset = 0)
|
||||||
|
.joinToString("\n") { "- [${it.category.name}] ${it.content.take(120)}" }
|
||||||
|
|
||||||
|
// Склеиваем все ходы в один промпт — модель посмотрит пакетом и сможет
|
||||||
|
// отсеять дубликаты между ходами.
|
||||||
|
val prompt = buildString {
|
||||||
|
if (existingFacts.isNotBlank()) {
|
||||||
|
appendLine("[memory-context — что уже сохранено]")
|
||||||
|
appendLine(existingFacts)
|
||||||
|
appendLine()
|
||||||
|
}
|
||||||
|
appendLine("[compacted-turns — будет удалено после compaction'а]")
|
||||||
|
turns.forEachIndexed { idx, t ->
|
||||||
|
appendLine()
|
||||||
|
appendLine("--- turn ${idx + 1} ---")
|
||||||
|
appendLine("[user] ${t.userMessage}")
|
||||||
|
appendLine("[assistant] ${t.assistantMessage}")
|
||||||
|
}
|
||||||
|
appendLine()
|
||||||
|
append("Верни JSON:")
|
||||||
|
}
|
||||||
|
|
||||||
|
val conv = liteLlm.createConversation(
|
||||||
|
pw.binom.litert.LiteConversationConfig(
|
||||||
|
systemInstruction = ReviewPrompts.REVIEW_SYSTEM_PROMPT,
|
||||||
|
temperature = 0.2f,
|
||||||
|
maxTokens = 1024,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val raw = try {
|
||||||
|
conv.send(prompt)
|
||||||
|
} finally {
|
||||||
|
conv.close()
|
||||||
|
}
|
||||||
|
ReviewDecisionParser.parse(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применяет решение к store: сохраняет новые заметки, удаляет помеченные.
|
||||||
|
* Возвращает сколько заметок записано/удалено — для метрик.
|
||||||
|
*/
|
||||||
|
suspend fun apply(decision: MemoryReviewDecision, source: pw.binom.agentik.memory.MemorySource): ApplyResult = withContext(dispatcher) {
|
||||||
|
var saved = 0
|
||||||
|
var deleted = 0
|
||||||
|
|
||||||
|
for (note in decision.toSave) {
|
||||||
|
store.upsert(toMemoryNote(note, source))
|
||||||
|
saved++
|
||||||
|
}
|
||||||
|
for (id in decision.toDelete) {
|
||||||
|
if (store.delete(id)) deleted++
|
||||||
|
}
|
||||||
|
ApplyResult(saved = saved, deleted = deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toMemoryNote(
|
||||||
|
note: NewMemoryNote,
|
||||||
|
source: pw.binom.agentik.memory.MemorySource,
|
||||||
|
): pw.binom.agentik.memory.MemoryNote {
|
||||||
|
val now = clock.now()
|
||||||
|
return pw.binom.agentik.memory.MemoryNote(
|
||||||
|
id = Ids.new("mem-review"),
|
||||||
|
category = note.category,
|
||||||
|
content = note.content,
|
||||||
|
createdAt = now,
|
||||||
|
lastUsedAt = now,
|
||||||
|
useCount = 0,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ApplyResult(val saved: Int, val deleted: Int)
|
||||||
|
}
|
||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent.memory
|
||||||
|
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryReviewDecision
|
||||||
|
import pw.binom.agentik.memory.NewMemoryNote
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсер ответа LLM-review-loop'а.
|
||||||
|
*
|
||||||
|
* LiteLlm (on-device) не имеет надёжного tool-calling flow, поэтому
|
||||||
|
* модель возвращает JSON в plain text. Парсим регуляркой + минимальным
|
||||||
|
* валидатором — если что-то не так, лучше no-op, чем краш.
|
||||||
|
*/
|
||||||
|
object ReviewDecisionParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит ответ модели в [MemoryReviewDecision]. Возвращает пустой decision
|
||||||
|
* если ответ пустой, не JSON, или JSON битый — лучше ничего не сохранить,
|
||||||
|
* чем записать мусор.
|
||||||
|
*/
|
||||||
|
fun parse(rawOutput: String): MemoryReviewDecision {
|
||||||
|
val trimmed = rawOutput.trim()
|
||||||
|
if (trimmed.isEmpty()) return MemoryReviewDecision()
|
||||||
|
|
||||||
|
// Ищем JSON-блок, даже если модель обернула его в ``` или добавила пояснения
|
||||||
|
val json = extractJson(trimmed) ?: return MemoryReviewDecision()
|
||||||
|
return parseJson(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractJson(text: String): String? {
|
||||||
|
// Первый '{' до последней '}'
|
||||||
|
val start = text.indexOf('{')
|
||||||
|
val end = text.lastIndexOf('}')
|
||||||
|
if (start < 0 || end < 0 || end <= start) return null
|
||||||
|
return text.substring(start, end + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальный JSON-парсер. Не хочу тянуть kotlinx-serialization в этот
|
||||||
|
* слой — JSON простой (плоский массив объектов), пишем руками.
|
||||||
|
*/
|
||||||
|
private fun parseJson(json: String): MemoryReviewDecision {
|
||||||
|
return try {
|
||||||
|
val save = parseArray(json, "save") { obj ->
|
||||||
|
val category = parseString(obj, "category")?.let { runCatching { MemoryCategory.valueOf(it.uppercase()) }.getOrNull() }
|
||||||
|
?: return@parseArray null
|
||||||
|
val content = parseString(obj, "content")?.takeIf { it.isNotBlank() }
|
||||||
|
?: return@parseArray null
|
||||||
|
NewMemoryNote(category, content)
|
||||||
|
}
|
||||||
|
val delete = parseStringArray(json, "delete")
|
||||||
|
MemoryReviewDecision(toSave = save, toDelete = delete)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
MemoryReviewDecision()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит массив объектов из JSON-строки по ключу. callback получает
|
||||||
|
* содержимое одного элемента (без обрамляющих []{} и без имени ключа)
|
||||||
|
* и возвращает элемент результата либо null (пропустить).
|
||||||
|
*/
|
||||||
|
private fun <T> parseArray(json: String, key: String, map: (String) -> T?): List<T> {
|
||||||
|
// Ищем "key": [ ... ]
|
||||||
|
val keyIdx = json.indexOf("\"$key\"")
|
||||||
|
if (keyIdx < 0) return emptyList()
|
||||||
|
val arrayStart = json.indexOf('[', keyIdx)
|
||||||
|
val arrayEnd = json.indexOf(']', arrayStart)
|
||||||
|
if (arrayStart < 0 || arrayEnd < 0) return emptyList()
|
||||||
|
|
||||||
|
val arrayContent = json.substring(arrayStart + 1, arrayEnd)
|
||||||
|
return splitTopLevelObjects(arrayContent).mapNotNull { map(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Разбивает содержимое JSON-массива на отдельные объекты верхнего уровня
|
||||||
|
* с учётом вложенности и экранирования кавычек.
|
||||||
|
*/
|
||||||
|
private fun splitTopLevelObjects(content: String): List<String> {
|
||||||
|
val result = mutableListOf<String>()
|
||||||
|
var depth = 0
|
||||||
|
var start = -1
|
||||||
|
var inString = false
|
||||||
|
var escaped = false
|
||||||
|
content.forEachIndexed { i, ch ->
|
||||||
|
if (escaped) { escaped = false; return@forEachIndexed }
|
||||||
|
when {
|
||||||
|
ch == '\\' && inString -> escaped = true
|
||||||
|
ch == '"' -> inString = !inString
|
||||||
|
!inString && ch == '{' -> {
|
||||||
|
if (depth == 0) start = i
|
||||||
|
depth++
|
||||||
|
}
|
||||||
|
!inString && ch == '}' -> {
|
||||||
|
depth--
|
||||||
|
if (depth == 0 && start >= 0) {
|
||||||
|
result.add(content.substring(start, i + 1))
|
||||||
|
start = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит массив строк из JSON по ключу. Используется для `delete`
|
||||||
|
* (там элементы — голые строки, не объекты).
|
||||||
|
*/
|
||||||
|
private fun parseStringArray(json: String, key: String): List<String> {
|
||||||
|
val keyIdx = json.indexOf("\"$key\"")
|
||||||
|
if (keyIdx < 0) return emptyList()
|
||||||
|
val arrayStart = json.indexOf('[', keyIdx)
|
||||||
|
val arrayEnd = json.indexOf(']', arrayStart)
|
||||||
|
if (arrayStart < 0 || arrayEnd < 0) return emptyList()
|
||||||
|
|
||||||
|
val arrayContent = json.substring(arrayStart + 1, arrayEnd)
|
||||||
|
val result = mutableListOf<String>()
|
||||||
|
var i = 0
|
||||||
|
while (i < arrayContent.length) {
|
||||||
|
// Skip whitespace and commas
|
||||||
|
while (i < arrayContent.length && (arrayContent[i].isWhitespace() || arrayContent[i] == ',')) i++
|
||||||
|
if (i >= arrayContent.length || arrayContent[i] != '"') break
|
||||||
|
i++ // skip opening quote
|
||||||
|
|
||||||
|
val sb = StringBuilder()
|
||||||
|
var escaped = false
|
||||||
|
while (i < arrayContent.length) {
|
||||||
|
val c = arrayContent[i]
|
||||||
|
if (escaped) {
|
||||||
|
when (c) {
|
||||||
|
'n' -> sb.append('\n')
|
||||||
|
't' -> sb.append('\t')
|
||||||
|
'r' -> sb.append('\r')
|
||||||
|
'"' -> sb.append('"')
|
||||||
|
'\\' -> sb.append('\\')
|
||||||
|
else -> sb.append(c)
|
||||||
|
}
|
||||||
|
escaped = false
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
when (c) {
|
||||||
|
'\\' -> { escaped = true; i++ }
|
||||||
|
'"' -> {
|
||||||
|
result.add(sb.toString())
|
||||||
|
i++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
else -> { sb.append(c); i++ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит строковое значение по ключу в JSON-объекте. Возвращает
|
||||||
|
* содержимое без обрамляющих кавычек и с раскрытыми базовыми escape.
|
||||||
|
*/
|
||||||
|
private fun parseString(obj: String, key: String): String? {
|
||||||
|
val keyIdx = obj.indexOf("\"$key\"")
|
||||||
|
if (keyIdx < 0) return null
|
||||||
|
val colon = obj.indexOf(':', keyIdx)
|
||||||
|
if (colon < 0) return null
|
||||||
|
val firstQuote = obj.indexOf('"', colon)
|
||||||
|
if (firstQuote < 0) return null
|
||||||
|
|
||||||
|
// Ищем закрывающую кавычку с учётом escape
|
||||||
|
var i = firstQuote + 1
|
||||||
|
val sb = StringBuilder()
|
||||||
|
while (i < obj.length) {
|
||||||
|
val c = obj[i]
|
||||||
|
when {
|
||||||
|
c == '\\' && i + 1 < obj.length -> {
|
||||||
|
when (val next = obj[i + 1]) {
|
||||||
|
'n' -> sb.append('\n')
|
||||||
|
't' -> sb.append('\t')
|
||||||
|
'r' -> sb.append('\r')
|
||||||
|
'"' -> sb.append('"')
|
||||||
|
'\\' -> sb.append('\\')
|
||||||
|
else -> sb.append(next)
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
c == '"' -> return sb.toString()
|
||||||
|
else -> {
|
||||||
|
sb.append(c)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent.memory
|
||||||
|
|
||||||
|
import pw.binom.agentik.memory.ReviewedTurn
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Промпты для review-loop'а через LiteLlm.
|
||||||
|
*
|
||||||
|
* Hermes делает это с OpenAI/Anthropic tool-calling flow. У нас on-device
|
||||||
|
* движок (Google LiteRT-LM) — там tool-calling ненадёжен, поэтому используем
|
||||||
|
* structured-output: модель должна вернуть JSON, который мы парсим регуляркой.
|
||||||
|
*/
|
||||||
|
object ReviewPrompts {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Системная инструкция для review-loop'а. На русском — модель у нас
|
||||||
|
* русскоязычная (gemma-2-9b / qwen / и т.п.), английский промпт часто
|
||||||
|
* даёт хуже результат на ru-данных.
|
||||||
|
*/
|
||||||
|
const val REVIEW_SYSTEM_PROMPT = """Ты — агент ревью памяти. Твоя задача — проанализировать пару (сообщение пользователя, ответ ассистента) и решить, что из неё стоит сохранить в долговременную память.
|
||||||
|
|
||||||
|
Категории памяти:
|
||||||
|
- USER — факты о пользователе (имя, профессия, предпочтения, контекст его жизни)
|
||||||
|
- WORLD — факты о внешнем мире (проекты, технологии, организации, конкретные API/документация)
|
||||||
|
- PREFERENCE — предпочтения по формату/поведению ассистента (стиль кода, длины ответов, инструменты)
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
1. Сохраняй ТОЛЬКО durable facts — то, что останется актуальным через недели. Не сохраняй "пользователь поздоровался" или "ассистент использовал grep".
|
||||||
|
2. Не дублируй уже сохранённое — если факт уже есть в [memory-context], пропусти.
|
||||||
|
3. Каждый факт — одна короткая фраза. Не абзацы, не "the user mentioned...".
|
||||||
|
4. Не выдумывай. Если ничего достойного — верни пустой массив.
|
||||||
|
|
||||||
|
Формат ответа — строго JSON без обрамления ```json и без пояснений:
|
||||||
|
{"save":[{"category":"USER|WORLD|PREFERENCE","content":"..."}],"delete":[]}
|
||||||
|
|
||||||
|
Если нечего сохранять:
|
||||||
|
{"save":[],"delete":[]}"""
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует user-prompt для review-loop'а. Подаёт текущий ход +
|
||||||
|
* текущее состояние долговременной памяти (чтобы избежать дубликатов).
|
||||||
|
*/
|
||||||
|
fun reviewUserPrompt(
|
||||||
|
turn: ReviewedTurn,
|
||||||
|
existingFacts: String,
|
||||||
|
): String = buildString {
|
||||||
|
if (existingFacts.isNotBlank()) {
|
||||||
|
appendLine("[memory-context — что уже сохранено]")
|
||||||
|
appendLine(existingFacts)
|
||||||
|
appendLine()
|
||||||
|
}
|
||||||
|
appendLine("[user]")
|
||||||
|
appendLine(turn.userMessage)
|
||||||
|
appendLine()
|
||||||
|
appendLine("[assistant]")
|
||||||
|
appendLine(turn.assistantMessage)
|
||||||
|
appendLine()
|
||||||
|
append("Верни JSON:")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -414,7 +414,7 @@ class ChatAgentTest {
|
|||||||
|
|
||||||
private fun fakeLiteLlmForReload(): LiteLlm = object : LiteLlm {
|
private fun fakeLiteLlmForReload(): LiteLlm = object : LiteLlm {
|
||||||
override val backendName: String = "fake"
|
override val backendName: String = "fake"
|
||||||
override val capabilities: pw.binom.litert.LiteCapabilities? = null
|
override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null)
|
||||||
override fun isInitialized(): Boolean = true
|
override fun isInitialized(): Boolean = true
|
||||||
override fun createConversation(config: LiteConversationConfig): LiteConversation =
|
override fun createConversation(config: LiteConversationConfig): LiteConversation =
|
||||||
error("not used in reload test")
|
error("not used in reload test")
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import pw.binom.litert.LiteRole
|
|||||||
*/
|
*/
|
||||||
internal class FakeLiteLlm : LiteLlm {
|
internal class FakeLiteLlm : LiteLlm {
|
||||||
override val backendName: String = "fake"
|
override val backendName: String = "fake"
|
||||||
override val capabilities: pw.binom.litert.LiteCapabilities? = null
|
override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null)
|
||||||
var reply: String = ""
|
var reply: String = ""
|
||||||
var rememberHistory: Boolean = false
|
var rememberHistory: Boolean = false
|
||||||
var slow: Boolean = false
|
var slow: Boolean = false
|
||||||
|
|||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent.memory
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryNote
|
||||||
|
import pw.binom.agentik.memory.MemorySource
|
||||||
|
import pw.binom.agentik.memory.MemoryStore
|
||||||
|
import pw.binom.agentik.memory.MemoryStoreEvent
|
||||||
|
import pw.binom.agentik.memory.ReviewedTurn
|
||||||
|
import pw.binom.agentik.standalone.agent.FakeLiteLlm
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class LlmMemoryReviewerTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальный in-memory store для тестов — реализует [MemoryStore],
|
||||||
|
* хранит заметки в MutableList, поддерживает events flow.
|
||||||
|
*/
|
||||||
|
private class InMemoryStore : MemoryStore {
|
||||||
|
private val notes = mutableMapOf<String, MemoryNote>()
|
||||||
|
private val _events = kotlinx.coroutines.flow.MutableSharedFlow<MemoryStoreEvent>(extraBufferCapacity = 16)
|
||||||
|
|
||||||
|
override suspend fun upsert(note: MemoryNote) {
|
||||||
|
notes[note.id] = note
|
||||||
|
_events.emit(MemoryStoreEvent.Upserted(note))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun get(id: String): MemoryNote? = notes[id]
|
||||||
|
override suspend fun list(
|
||||||
|
category: MemoryCategory?,
|
||||||
|
conversationId: String?,
|
||||||
|
limit: Int,
|
||||||
|
offset: Int,
|
||||||
|
): List<MemoryNote> = notes.values
|
||||||
|
.filter { category == null || it.category == category }
|
||||||
|
.filter { conversationId == null || it.conversationId == conversationId }
|
||||||
|
.sortedByDescending { it.lastUsedAt }
|
||||||
|
.drop(offset)
|
||||||
|
.take(limit)
|
||||||
|
|
||||||
|
override suspend fun search(query: pw.binom.agentik.memory.MemorySearchQuery): List<pw.binom.agentik.memory.MemorySearchResult> = emptyList()
|
||||||
|
|
||||||
|
override suspend fun delete(id: String): Boolean = notes.remove(id) != null
|
||||||
|
|
||||||
|
override suspend fun markUsed(id: String, at: Instant) {
|
||||||
|
notes[id]?.let {
|
||||||
|
notes[id] = it.copy(lastUsedAt = at, useCount = it.useCount + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun events(): kotlinx.coroutines.flow.Flow<MemoryStoreEvent> = _events
|
||||||
|
|
||||||
|
override fun close() {}
|
||||||
|
|
||||||
|
// Helper for tests to seed notes
|
||||||
|
fun seed(note: MemoryNote) {
|
||||||
|
notes[note.id] = note
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `review parses save JSON and applies upsert`() = runTest {
|
||||||
|
val llm = FakeLiteLlm().apply {
|
||||||
|
reply = """{"save":[{"category":"USER","content":"Имя — Саша"}],"delete":[]}"""
|
||||||
|
}
|
||||||
|
val store = InMemoryStore()
|
||||||
|
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
val decision = reviewer.review(
|
||||||
|
ReviewedTurn(
|
||||||
|
userMessage = "Меня Саша зовут",
|
||||||
|
assistantMessage = "Приятно познакомиться, Саша!",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(1, decision.toSave.size)
|
||||||
|
assertEquals(MemoryCategory.USER, decision.toSave[0].category)
|
||||||
|
|
||||||
|
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
|
||||||
|
assertEquals(1, applied.saved)
|
||||||
|
|
||||||
|
val all = store.list()
|
||||||
|
assertEquals(1, all.size)
|
||||||
|
assertEquals("Имя — Саша", all[0].content)
|
||||||
|
assertEquals(MemorySource.AUTO_REVIEW, all[0].source)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `review applies delete decisions`() = runTest {
|
||||||
|
val llm = FakeLiteLlm().apply {
|
||||||
|
reply = """{"save":[],"delete":["mem-stale-1"]}"""
|
||||||
|
}
|
||||||
|
val store = InMemoryStore().apply {
|
||||||
|
seed(
|
||||||
|
MemoryNote(
|
||||||
|
id = "mem-stale-1",
|
||||||
|
category = MemoryCategory.USER,
|
||||||
|
content = "stale",
|
||||||
|
createdAt = Instant.parse("2026-01-01T00:00:00Z"),
|
||||||
|
lastUsedAt = Instant.parse("2026-01-01T00:00:00Z"),
|
||||||
|
useCount = 0,
|
||||||
|
source = MemorySource.AUTO_REVIEW,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
val decision = reviewer.review(ReviewedTurn("удали это", "ок"))
|
||||||
|
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
|
||||||
|
assertEquals(0, applied.saved)
|
||||||
|
assertEquals(1, applied.deleted)
|
||||||
|
assertEquals(0, store.list().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `review returns empty decision when LLM produces garbage`() = runTest {
|
||||||
|
val llm = FakeLiteLlm().apply { reply = "Извини, я не могу помочь с этим." }
|
||||||
|
val store = InMemoryStore()
|
||||||
|
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
val decision = reviewer.review(ReviewedTurn("hi", "hello"))
|
||||||
|
assertTrue(decision.toSave.isEmpty())
|
||||||
|
assertTrue(decision.toDelete.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `review handles empty LLM reply`() = runTest {
|
||||||
|
val llm = FakeLiteLlm().apply { reply = "" }
|
||||||
|
val store = InMemoryStore()
|
||||||
|
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
val decision = reviewer.review(ReviewedTurn("hi", "hello"))
|
||||||
|
assertTrue(decision.toSave.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `review creates conversation with review system prompt`() = runTest {
|
||||||
|
val llm = FakeLiteLlm().apply {
|
||||||
|
reply = """{"save":[],"delete":[]}"""
|
||||||
|
}
|
||||||
|
val store = InMemoryStore()
|
||||||
|
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
reviewer.review(ReviewedTurn("u", "a"))
|
||||||
|
|
||||||
|
assertNotNull(llm.lastConfig)
|
||||||
|
assertEquals(ReviewPrompts.REVIEW_SYSTEM_PROMPT, llm.lastConfig!!.systemInstruction)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reviewPreCompaction processes batch of turns`() = runTest {
|
||||||
|
val llm = FakeLiteLlm().apply {
|
||||||
|
reply = """
|
||||||
|
{"save":[
|
||||||
|
{"category":"USER","content":"Работает в Яндексе"},
|
||||||
|
{"category":"WORLD","content":"JVector — pure-Java ANN"}
|
||||||
|
],"delete":[]}
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
val store = InMemoryStore()
|
||||||
|
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
val turns = listOf(
|
||||||
|
pw.binom.agentik.memory.ConversationTurn(
|
||||||
|
userMessage = "Я в Яндексе работаю",
|
||||||
|
assistantMessage = "Круто!",
|
||||||
|
),
|
||||||
|
pw.binom.agentik.memory.ConversationTurn(
|
||||||
|
userMessage = "А что за JVector?",
|
||||||
|
assistantMessage = "ANN-библиотека на Java.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val decision = reviewer.reviewPreCompaction(turns)
|
||||||
|
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
|
||||||
|
|
||||||
|
assertEquals(2, applied.saved)
|
||||||
|
assertEquals(2, store.list().size)
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
package pw.binom.agentik.standalone.agent.memory
|
||||||
|
|
||||||
|
import pw.binom.agentik.memory.MemoryCategory
|
||||||
|
import pw.binom.agentik.memory.MemoryReviewDecision
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class ReviewDecisionParserTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses save array with USER category`() {
|
||||||
|
val raw = """{"save":[{"category":"USER","content":"Имя пользователя — Саша"}],"delete":[]}"""
|
||||||
|
val decision = ReviewDecisionParser.parse(raw)
|
||||||
|
assertEquals(1, decision.toSave.size)
|
||||||
|
assertEquals(MemoryCategory.USER, decision.toSave[0].category)
|
||||||
|
assertEquals("Имя пользователя — Саша", decision.toSave[0].content)
|
||||||
|
assertTrue(decision.toDelete.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses all three categories`() {
|
||||||
|
val raw = """
|
||||||
|
{"save":[
|
||||||
|
{"category":"USER","content":"Работает в Яндексе"},
|
||||||
|
{"category":"WORLD","content":"JVector — pure-Java ANN от DataStax"},
|
||||||
|
{"category":"PREFERENCE","content":"Отвечать кратко"}
|
||||||
|
],"delete":[]}
|
||||||
|
""".trimIndent()
|
||||||
|
val decision = ReviewDecisionParser.parse(raw)
|
||||||
|
assertEquals(3, decision.toSave.size)
|
||||||
|
assertEquals(MemoryCategory.USER, decision.toSave[0].category)
|
||||||
|
assertEquals(MemoryCategory.WORLD, decision.toSave[1].category)
|
||||||
|
assertEquals(MemoryCategory.PREFERENCE, decision.toSave[2].category)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses delete array with ids`() {
|
||||||
|
val raw = """{"save":[],"delete":["mem-123","mem-456"]}"""
|
||||||
|
val decision = ReviewDecisionParser.parse(raw)
|
||||||
|
assertTrue(decision.toSave.isEmpty())
|
||||||
|
assertEquals(listOf("mem-123", "mem-456"), decision.toDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns empty decision on empty input`() {
|
||||||
|
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(""))
|
||||||
|
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns empty decision on non-JSON garbage`() {
|
||||||
|
val raw = "Извини, я не могу помочь с этим."
|
||||||
|
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns empty decision on malformed JSON`() {
|
||||||
|
val raw = """{"save":[{"category":"USER","content":"foo""" // truncated
|
||||||
|
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extracts JSON from markdown code block`() {
|
||||||
|
val raw = """
|
||||||
|
Вот JSON:
|
||||||
|
```json
|
||||||
|
{"save":[{"category":"WORLD","content":"SQLite 3.51"}],"delete":[]}
|
||||||
|
```
|
||||||
|
""".trimIndent()
|
||||||
|
val decision = ReviewDecisionParser.parse(raw)
|
||||||
|
assertEquals(1, decision.toSave.size)
|
||||||
|
assertEquals(MemoryCategory.WORLD, decision.toSave[0].category)
|
||||||
|
assertEquals("SQLite 3.51", decision.toSave[0].content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skips entries with unknown category`() {
|
||||||
|
val raw = """{"save":[
|
||||||
|
{"category":"USER","content":"valid"},
|
||||||
|
{"category":"NOT_A_CATEGORY","content":"should be skipped"},
|
||||||
|
{"category":"WORLD","content":"valid too"}
|
||||||
|
],"delete":[]}"""
|
||||||
|
val decision = ReviewDecisionParser.parse(raw)
|
||||||
|
assertEquals(2, decision.toSave.size)
|
||||||
|
assertEquals("valid", decision.toSave[0].content)
|
||||||
|
assertEquals("valid too", decision.toSave[1].content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `skips entries with blank content`() {
|
||||||
|
val raw = """{"save":[
|
||||||
|
{"category":"USER","content":""},
|
||||||
|
{"category":"WORLD","content":" "}
|
||||||
|
],"delete":[]}"""
|
||||||
|
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `handles escaped quotes in content`() {
|
||||||
|
val raw = """{"save":[{"category":"USER","content":"Сказал \"привет\""}],"delete":[]}"""
|
||||||
|
val decision = ReviewDecisionParser.parse(raw)
|
||||||
|
assertEquals(1, decision.toSave.size)
|
||||||
|
assertEquals("Сказал \"привет\"", decision.toSave[0].content)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user