Phase 5: :memory-vector (JVector + SQLite + LLM-эмбеддинги), memory-abstraction, compaction, MessageContext

- :memory-api — общий контракт MemoryStore/Prefetcher/Reviewer/Tools/MemorySystem
- :memory-md (KMP, kotlinx-io) — Hermes-style §-файлы, keyword overlap
- :memory-vector (JVM-only) — JVector ANN + SQLite + HttpEmbeddingClient
- :standalone — AGENTIK_MEMORY_BACKEND={md,vector,off}, выбор в Main.kt
- :standalone — compaction рабочего контекста (LiteLlmContextCompactor + reviewPreCompaction)
- :proto — MessageContext (origin: user/system/event) на send и в Message
- :server — backward-compat dual-format для POST /messages
- README — env-vars, vector-бэкенд docs
This commit is contained in:
2026-09-15 03:44:15 +03:00
parent 0faad45f3d
commit 65365da89c
71 changed files with 5856 additions and 142 deletions
@@ -0,0 +1,41 @@
package pw.binom.agentik.standalone.persistence
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Контекст инициации хода (кто/что и почему).
*
* Дубликат типа из `:proto` (`pw.binom.agentik.proto.MessageContext`):
* живёт в `:standalone/persistence` чтобы не тащить `:proto` в слой
* хранения данных. Маппинг между ними — в `MessageRecord.toProto()` /
* `ProtoMessage.toStorage()`.
*
* Используется:
* - в `MessageRecord.UserMessage.context` — фиксируется в audit log;
* - в `WorkingMemoryEntry.User.context` — попадает в LLM-нагрузку
* как префикс к user-тексту (для не-USER origin'ов).
*
* Сериализация в `payload_json` (SQLite) — через kotlinx-serialization,
* формат snake_case для enum origin.
*/
@Serializable
enum class MessageOrigin {
@SerialName("user")
USER,
@SerialName("system")
SYSTEM,
@SerialName("event")
EVENT,
}
@Serializable
data class MessageContext(
val origin: MessageOrigin,
val description: String? = null,
val sourceId: String? = null,
val metadata: JsonElement? = null,
)
@@ -37,6 +37,13 @@ sealed interface MessageRecord {
override val conversationId: String,
override val content: List<Content>,
override val createdAt: Instant,
/**
* Контекст инициации хода: кто/что вызвал этот turn. `null` —
* обычное user-сообщение. См. [MessageContext].
*
* Persisted через `payload_json` SQLite (см. `Payload.kt`).
*/
val context: MessageContext? = null,
) : Body
@Serializable
@@ -1,11 +1,22 @@
package pw.binom.agentik.standalone.persistence
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
/**
* JSON-формат для тел user/assistant сообщений: список [Content],
* сериализованный в строку (через kotlinx-serialization).
* JSON-формат для тел user/assistant сообщений: список [Content], опционально
* с [MessageContext] (для user — кто инициировал ход).
*
* Encoded-формат:
* ```
* {"content": [ ...Content ], "context": {...MessageContext?}}
* ```
*
* Backward compat: при чтении старых строк, где payload был просто
* `[ ... ]` (без обёртки), парсер падает на wrapper-формат и fallback'ит
* к `ListSerializer<Content>` — такие строки возвращаются с `context = null`.
*/
private val bodyJson = Json {
ignoreUnknownKeys = true
@@ -13,14 +24,40 @@ private val bodyJson = Json {
explicitNulls = false
}
/**
* Сериализует список [Content] в JSON-строку для хранения в `payload_json`.
*/
fun encodeBodyPayload(content: List<Content>): String =
bodyJson.encodeToString(ListSerializer(Content.serializer()), content)
@Serializable
internal data class MessageBodyPayload(
val content: List<Content>,
@SerialName("context")
val context: MessageContext? = null,
)
/**
* Десериализует список [Content] из JSON-строки `payload_json`.
* Сериализует тело user (или assistant) сообщения в JSON-строку для
* `payload_json` SQLite. Для user может нести [context] — кто инициировал ход.
*/
fun decodeBodyPayload(json: String): List<Content> =
bodyJson.decodeFromString(ListSerializer(Content.serializer()), json)
fun encodeBodyPayload(content: List<Content>, context: MessageContext? = null): String =
bodyJson.encodeToString(
MessageBodyPayload.serializer(),
MessageBodyPayload(content = content, context = context),
)
/**
* Десериализует тело сообщения: возвращает пару `(content, context)`.
* Контекст null если:
* - в строке нет поля `context` (новый формат, обычный user);
* - payload в старом plain-array формате (миграция не нужна — fallback).
*/
fun decodeBodyPayload(json: String): BodyDecoded = readPayload(json)
data class BodyDecoded(val content: List<Content>, val context: MessageContext?)
private fun readPayload(json: String): BodyDecoded {
return try {
val p = bodyJson.decodeFromString(MessageBodyPayload.serializer(), json)
BodyDecoded(p.content, p.context)
} catch (e: kotlinx.serialization.SerializationException) {
// Старый формат: голый JSON-массив Content, без обёртки.
val arr = bodyJson.decodeFromString(ListSerializer(Content.serializer()), json)
BodyDecoded(arr, null)
}
}
@@ -30,6 +30,13 @@ sealed interface WorkingMemoryEntry {
data class User(
override val sourceMessageId: String,
val content: List<Content>,
/**
* Контекст инициации хода. Применяется при сборке `LiteConversation`:
* если `origin != USER`, текст префиксуется `[origin] description (sourceId=…)`,
* чтобы модель видела, что её разбудил не пользователь.
* `null` = обычное user-сообщение.
*/
val context: MessageContext? = null,
) : WorkingMemoryEntry
/** Реплика ассистента. */
@@ -39,4 +46,19 @@ sealed interface WorkingMemoryEntry {
override val sourceMessageId: String,
val content: List<Content>,
) : WorkingMemoryEntry
/**
* Синтетический блок: суммаризация старых ходов, сгенерированная при
* compaction'е working memory. Не имеет ссылки на конкретное сообщение
* в audit log — это наша собственная интерпретация контекста.
*/
@Serializable
@SerialName("summary")
data class Summary(
val text: String,
/** Ходы, которые были свёрнуты в этот summary (диапазон order_idx в виде меты). */
val coversUpToOrderIdx: Long? = null,
) : WorkingMemoryEntry {
override val sourceMessageId: String? = null
}
}
@@ -40,11 +40,19 @@ interface WorkingMemoryStore : AutoCloseable {
suspend fun clear(conversationId: String)
/**
* Атомарная суммаризация (v2): удаляет все строки с `order_idx` в диапазоне
* `[dropFromOrderIdx, +∞)` и вставляет вместо них новый `summary` с
* указанным текстом. Возвращает новый максимальный `order_idx`.
* Атомарная суммаризация: удаляет все строки с `order_idx` в диапазоне
* `[dropFromOrderIdx, +∞)`. Если [summaryText] непустое — вместо удалённых
* строк вставляется одна синтетическая [WorkingMemoryEntry.Summary]
* с этим текстом и `order_idx = max(old order_idx after delete) + 1`
* (т.е. summary становится хвостом working memory).
*
* Для v1 просто удаляет — суммаризация появится в v2.
* Если [summaryText] == null — работает как «отрезать хвост» (v1 поведение).
*
* Возвращает новый максимальный `order_idx` после операции.
*/
suspend fun compact(dropFromOrderIdx: Long, conversationId: String): Long
suspend fun compact(
dropFromOrderIdx: Long,
conversationId: String,
summaryText: String? = null,
): Long
}
@@ -5,11 +5,19 @@ import io.ktor.server.engine.embeddedServer
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import kotlinx.io.files.Path
import pw.binom.agentik.memory.MemorySystem
import pw.binom.agentik.memory.md.openMdMemorySystem
import pw.binom.agentik.memory.vector.VectorMemorySystem
import pw.binom.agentik.memory.vector.embedding.HttpEmbeddingClient
import pw.binom.agentik.server.agentikAgent
import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillLoader
import pw.binom.agentik.standalone.agent.ChatAgent
import pw.binom.agentik.standalone.agent.LiteLlmContextCompactor
import pw.binom.agentik.standalone.config.AgentikConfig
import pw.binom.agentik.standalone.config.AgentikConfig.MemoryBackend
import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.mcp.McpRegistry
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
import java.io.File
@@ -46,6 +54,70 @@ fun main() {
result.errors.forEach { System.err.println("[agentik] skill '${it.path}': ${it.message}") }
result.catalog
} ?: SkillCatalog.EMPTY
// SOUL.md — файл персоны. Если задан — читается как plain text/markdown,
// вставляется в самое начало systemInstruction. Если отсутствует — exit-code != 0
// (на старте агента это фатально: нечего показывать LLM).
val soulBody = config.soulPath?.let { path ->
val file = File(path)
if (!file.exists() || !file.isFile) {
System.err.println("[agentik] SOUL file not found: $path")
null
} else {
file.readText(Charsets.UTF_8)
}
}
// Долговременная память: выбор бэкенда через AGENTIK_MEMORY_BACKEND
// - MD (дефолт) — Hermes-style §-файлы в AGENTIK_MEMORY_DIR (~/.agentik/memory)
// - VECTOR — SQLite + JVector + LLM-эмбеддинги (тот же agentik.db для metadata)
// - OFF — память выключена (memoryDir="off" или memoryBackend="off")
val rawMemory = config.memoryDir
val memorySystem: MemorySystem? = when (config.memoryBackend) {
MemoryBackend.OFF -> {
println(" memory: disabled")
null
}
MemoryBackend.MD -> {
if (rawMemory.equals("off", ignoreCase = true)) {
println(" memory: disabled (memoryDir=off)")
null
} else {
val dir = rawMemory ?: defaultMemoryDir()
openMdMemorySystem(Path(dir)).also {
println(" memory: dir=$dir (md-backend)")
}
}
}
MemoryBackend.VECTOR -> {
val llm = config.llm
// Берём базовый URL + API key у активного LLM-бэкенда.
// Поддерживается только OPENAI (LiteLLM proxy тоже работает, т.к. /v1/embeddings
// — это OpenAI-совместимый endpoint).
require(llm.backend == LlmBackend.OPENAI) {
"AGENTIK_MEMORY_BACKEND=vector требует LLM_BACKEND=openai (нужен /v1/embeddings)"
}
val oa = checkNotNull(llm.openai) { "openai config required for vector backend" }
val embedding = HttpEmbeddingClient(
apiUrl = oa.baseUrl.trimEnd('/'),
apiKey = oa.apiKey,
model = config.embeddingModel,
dimension = config.embeddingDimension,
)
VectorMemorySystem.open(
dbPath = config.dbPath,
embedding = embedding,
).also {
println(" memory: db=${config.dbPath} (vector-backend, model=${config.embeddingModel}, dim=${config.embeddingDimension})")
}
}
}
// Контекстное окно модели (для compaction'а working memory).
// Если null — compaction выключен. Резолвится один раз из LlmConfig/env.
val contextWindow: Int? = config.llm.resolveContextWindow()
val contextCompactor = if (contextWindow != null) LiteLlmContextCompactor(liteLlm = llm) else null
val agent = ChatAgent(
id = "agentik",
stores = stores,
@@ -53,6 +125,13 @@ fun main() {
llmConfig = config.llm,
tools = mcpRegistry.namedTools,
skills = skills,
memoryStore = memorySystem?.store,
memoryPrefetcher = memorySystem?.prefetcher,
memoryReviewer = memorySystem?.reviewer,
soulBody = soulBody,
contextWindow = contextWindow,
compressionThreshold = config.compressionThreshold,
contextCompactor = contextCompactor,
)
val server = embeddedServer(CIO, port = config.port) {
@@ -69,11 +148,24 @@ fun main() {
println(" llm: ${config.llm.backend} ${config.llm.modelInfo()}")
println(" mcp: ${mcpRegistry.allTools.size} tools from ${mcpRegistry.connectedServerCount} servers")
println(" skills: ${skills.size} loaded${config.skillsDir?.let { " from $it" } ?: ""}")
if (config.soulPath != null) println(" soul: ${config.soulPath} (${soulBody?.length ?: 0} chars)")
println(" memory: ${if (memorySystem == null) "disabled" else "${config.memoryBackend.name.lowercase()}-backend"}")
if (contextWindow != null) {
println(" compaction: enabled, threshold=${config.compressionThreshold}, window=$contextWindow tokens")
} else {
println(" compaction: disabled (OPENAI_CONTEXT_WINDOW not set)")
}
Runtime.getRuntime().addShutdownHook(Thread {
agent.close()
mcpRegistry.close()
stores.close()
llm.close()
memorySystem?.close()
})
server.start(wait = true)
}
private fun defaultMemoryDir(): String {
val home = System.getProperty("user.home") ?: "."
return "$home/.agentik/memory"
}
@@ -6,11 +6,15 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.memory.MemoryPrefetcher
import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemorySystemGuidance
import pw.binom.agentik.proto.Agent as ProtoAgent
import pw.binom.agentik.proto.AgentEvent
import pw.binom.agentik.proto.Conversation as ProtoConversation
import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.renderSystemPromptSection
import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.standalone.persistence.ConversationRecord
import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry
@@ -31,6 +35,12 @@ import kotlin.time.Instant
* Если задан [skills], их каталог (имя + краткое описание) подмешивается в
* системный промпт, а в набор тулов добавляется встроенный `read_skill` для
* загрузки полного текста навыка по требованию.
*
* Если задана память ([memoryStore] + [memoryPrefetcher] + [memoryReviewer]),
* агенту также доступны тулы `memory_save` / `memory_read` / `memory_list` /
* `memory_delete`, в system prompt добавляется секция про память, а в каждом
* `ChatConversation` настраивается prefetch (контекст в начале user-сообщения)
* и post-turn reviewer (извлечение фактов после каждого хода).
*/
class ChatAgent(
override val id: String,
@@ -39,21 +49,51 @@ class ChatAgent(
private val llmConfig: LlmConfig,
private val tools: List<NamedTool> = emptyList(),
private val skills: SkillCatalog = SkillCatalog.EMPTY,
private val memoryStore: pw.binom.agentik.memory.MemoryStore? = null,
private val memoryPrefetcher: MemoryPrefetcher? = null,
private val memoryReviewer: MemoryReviewer? = null,
/**
* Тело SOUL.md — markdown-описание персоны. Вставляется в самое начало
* системного промпта, поверх базы, навыков и memory-guidance. `null` —
* секция персоны не добавляется.
*/
private val soulBody: String? = null,
/**
* Лимит контекстного окна модели в токенах. `null` — compaction выключен.
* См. [ChatConversation.compactPreTurnIfNeeded].
*/
private val contextWindow: Int? = null,
/**
* Порог compaction'а (доля от [contextWindow]). Дефолт `0.8`.
*/
private val compressionThreshold: Double = 0.8,
/**
* Сжиматель контекста. Вызывается только при превышении [compressionThreshold].
*/
private val contextCompactor: ContextCompactor? = null,
) : ProtoAgent, AutoCloseable {
/**
* Системный промпт + секция навыков (если скилы загружены). Именно он
* сидируется в working memory и передаётся в [ChatConversation].
* Системный промпт: (soul, если задан) → база → секция навыков → секция памяти.
* Именно он сидируется в working memory и передаётся в [ChatConversation].
*/
private val systemPrompt: String = buildSystemPrompt(llmConfig.systemPrompt, skills)
private val systemPrompt: String = buildSystemPrompt(
base = llmConfig.systemPrompt,
skills = skills,
memoryEnabled = memoryStore != null,
soulBody = soulBody,
)
/**
* Тулы, которые видит модель: внешние ([tools], обычно MCP) + встроенный
* `read_skill`, если есть скилы. MCP-тулы префиксованы `server__`, так что
* коллизия с `read_skill` невозможна.
* Тулы, которые видит модель: внешние ([tools], обычно MCP) + встроенные
* (`read_skill`, если есть скилы; `memory_*`, если подключена память).
* MCP-тулы префиксованы `server__`, так что коллизий нет.
*/
private val allTools: List<NamedTool> =
if (skills.isEmpty) tools else tools + NamedTool(SkillReadTool.NAME, SkillReadTool(skills))
private val allTools: List<NamedTool> = buildList {
addAll(tools)
if (!skills.isEmpty) add(NamedTool(SkillReadTool.NAME, SkillReadTool(skills)))
if (memoryStore != null) addAll(MemoryToolsFactory.create(memoryStore))
}
private val agentEvents = MutableSharedFlow<AgentEvent>(
extraBufferCapacity = 64,
@@ -93,7 +133,19 @@ class ChatAgent(
)
}
}
val conv = ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = systemPrompt, tools = allTools)
val conv = ChatConversation(
record = rec,
stores = stores,
llm = llm,
systemPrompt = systemPrompt,
tools = allTools,
memoryPrefetcher = memoryPrefetcher,
memoryReviewer = memoryReviewer,
memoryStoreForReview = memoryStore,
contextWindow = contextWindow,
compressionThreshold = compressionThreshold,
contextCompactor = contextCompactor,
)
runBlocking {
liveLock.withLock { live[conv.id] = conv }
}
@@ -104,7 +156,7 @@ class ChatAgent(
override suspend fun getConversation(id: String): ProtoConversation? {
liveLock.withLock { live[id] }?.let { if (!it.isClosed) return it }
val rec = stores.conversations.get(id) ?: return null
return ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = systemPrompt, tools = allTools).also {
return newConversation(rec).also {
liveLock.withLock { live[id] = it }
}
}
@@ -120,11 +172,25 @@ class ChatAgent(
override suspend fun getConversations(offset: Int, limit: Int): List<ProtoConversation> =
stores.conversations.list(offset = offset, limit = limit).map { rec ->
liveLock.withLock { live[rec.id] }
?: ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = systemPrompt, tools = allTools).also {
?: newConversation(rec).also {
liveLock.withLock { live[rec.id] = it }
}
}
private fun newConversation(rec: ConversationRecord): ChatConversation = ChatConversation(
record = rec,
stores = stores,
llm = llm,
systemPrompt = systemPrompt,
tools = allTools,
memoryPrefetcher = memoryPrefetcher,
memoryReviewer = memoryReviewer,
memoryStoreForReview = memoryStore,
contextWindow = contextWindow,
compressionThreshold = compressionThreshold,
contextCompactor = contextCompactor,
)
override fun close() {
runBlocking {
liveLock.withLock {
@@ -146,7 +212,11 @@ class ChatAgent(
* Собирает итоговый системный промпт: базовый текст + секция навыков
* (только если скилы есть). Пустая секция → базовый промпт без изменений.
*/
internal fun buildSystemPrompt(base: String, skills: SkillCatalog): String {
val section = skills.renderSystemPromptSection()
return if (section.isBlank()) base.trimEnd() else base.trimEnd() + "\n\n" + section
internal fun buildSystemPrompt(base: String, skills: SkillCatalog, memoryEnabled: Boolean, soulBody: String? = null): String {
val trimmedBase = base.trimEnd()
val skillsSection = skills.renderSystemPromptSection()
val withSkills = if (skillsSection.isBlank()) trimmedBase else trimmedBase + "\n\n" + skillsSection
val withMemory = if (memoryEnabled) withSkills + "\n\n" + MemorySystemGuidance.MEMORY_GUIDANCE else withSkills
val trimmedSoul = soulBody?.trim()
return if (!trimmedSoul.isNullOrEmpty()) trimmedSoul + "\n\n" + withMemory else withMemory
}
@@ -12,16 +12,26 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemoryPrefetcher
import pw.binom.agentik.memory.MemoryReviewDecision
import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.ReviewedTurn
import pw.binom.agentik.standalone.agent.memory.materializeReviewNote
import pw.binom.agentik.proto.Content as ProtoContent
import pw.binom.agentik.proto.Conversation as ProtoConversation
import pw.binom.agentik.proto.Event as ProtoEvent
import pw.binom.agentik.proto.Message as ProtoMessage
import pw.binom.agentik.proto.MessageContext as ProtoMessageContext
import pw.binom.agentik.standalone.persistence.Content
import pw.binom.agentik.standalone.persistence.ConversationRecord
import pw.binom.agentik.standalone.persistence.ConversationStore
import pw.binom.agentik.standalone.persistence.MessageContext
import pw.binom.agentik.standalone.persistence.MessageOrigin
import pw.binom.agentik.standalone.persistence.MessageRecord
import pw.binom.agentik.standalone.persistence.MessageStore
import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry
import pw.binom.agentik.standalone.persistence.WorkingMemoryRow
import pw.binom.agentik.standalone.persistence.WorkingMemoryStore
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
import pw.binom.litert.LiteContentPart
@@ -59,6 +69,42 @@ class ChatConversation(
private val llm: LiteLlm,
private val systemPrompt: String,
private val tools: List<NamedTool> = emptyList(),
/**
* Если задан, перед каждым ходом прогоняет user-сообщение через префетч
* и приклеивает топ-K заметок к первому текстовому контенту в виде
* префикса. См. `pw.binom.agentik.memory.MemorySystemGuidance.MEMORY_GUIDANCE`.
*/
private val memoryPrefetcher: MemoryPrefetcher? = null,
/**
* Если задан, после каждого завершённого хода (включая ошибочные)
* запускает фоновую корутину, которая извлекает из пары user/assistant
* новые заметки и кладёт их в [MemoryStore]. Для temp-бесед и без
* подключённого [MemoryReviewer] ничего не делается.
*/
private val memoryReviewer: MemoryReviewer? = null,
/**
* Хранилище, в которое [memoryReviewer] записывает новые заметки.
* Обязательно для работы ревьюера.
*/
private val memoryStoreForReview: pw.binom.agentik.memory.MemoryStore? = null,
/**
* Лимит контекстного окна модели в токенах. `null` — compaction выключен.
* Резолвится один раз в [pw.binom.agentik.standalone.Main.kt] из
* `OPENAI_CONTEXT_WINDOW` / `AGENTIK_GOOGLE_CONTEXT_WINDOW` /
* `LlmConfig.openai.contextWindow`.
*/
private val contextWindow: Int? = null,
/**
* Порог compaction'а (доля от [contextWindow]). Когда estimated tokens /
* contextWindow >= threshold — запускается [compactPreTurn]. Дефолт `0.8`.
*/
private val compressionThreshold: Double = 0.8,
/**
* Сжиматель контекста. Вызывается только при превышении [compressionThreshold].
* Если `null` — compaction пропускается, даже если лимит задан (агент
* продолжит работать как раньше).
*/
private val contextCompactor: ContextCompactor? = null,
) : ProtoConversation, AutoCloseable {
private var record: ConversationRecord = record
@@ -101,16 +147,18 @@ class ChatConversation(
record = newRecord
}
override suspend fun send(content: List<ProtoContent>) {
override suspend fun send(content: List<ProtoContent>, context: ProtoMessageContext?) {
check(!closed) { "Conversation closed: $id" }
val turnStarted = now()
val userMessageId = newId("msg")
val storageContext = context?.toStorage()
val userRecord = MessageRecord.UserMessage(
id = userMessageId,
conversationId = id,
content = content.map { it.toStorage() },
createdAt = turnStarted,
context = storageContext,
)
if (!record.isTemporal) {
@@ -120,6 +168,7 @@ class ChatConversation(
entry = WorkingMemoryEntry.User(
sourceMessageId = userMessageId,
content = userRecord.content,
context = storageContext,
),
now = turnStarted,
)
@@ -163,6 +212,10 @@ class ChatConversation(
* по tool-results в истории), повторяем. Защита от зацикливания — [MAX_TOOL_LOOPS].
*/
private suspend fun runTurn(userRecord: MessageRecord.UserMessage, turnStarted: Instant) {
if (!record.isTemporal) {
compactPreTurnIfNeeded()
}
emitEvent(ProtoEvent.StartReasoning(date = turnStarted))
emitEvent(ProtoEvent.StartResponse(date = now(), responseType = ProtoEvent.ResponseType.TEXT))
@@ -174,13 +227,21 @@ class ChatConversation(
null
}
}
}
}.let { baseParts -> applyContextPrefix(baseParts, userRecord.context) }
if (parts.isEmpty()) {
failTurn("Empty user input (no text content)")
return
}
val initialParts = buildList {
val memoryBlock = buildMemoryPrefix(parts)
if (memoryBlock != null) {
add(LiteContentPart.Text(memoryBlock))
}
addAll(parts)
}
val liteConv = try {
getOrCreateLiteConversation(excludeUserSourceId = if (record.isTemporal) null else userRecord.id)
} catch (e: Throwable) {
@@ -190,7 +251,7 @@ class ChatConversation(
}
val reply = StringBuilder()
var currentParts: List<LiteContentPart> = parts
var currentParts: List<LiteContentPart> = initialParts
var loopGuard = 0
while (loopGuard++ < MAX_TOOL_LOOPS) {
@@ -254,9 +315,246 @@ class ChatConversation(
conversationStore.touch(id, assistantAt)
}
scheduleReview(userRecord, assistantContent)
emitEvent(ProtoEvent.End(date = assistantAt))
}
/**
* Извлечь из user-текста префикс с релевантными заметками памяти. Возвращает
* `null`, если префетчер не задан, запрос пустой, или заметок не нашлось.
* Блок вставляется **до** пользовательского сообщения и помечен, чтобы
* LLM понимала, что это контекст, а не инструкция.
*/
private suspend fun buildMemoryPrefix(parts: List<LiteContentPart>): String? {
val prefetcher = memoryPrefetcher ?: return null
val userText = parts.asSequence()
.filterIsInstance<LiteContentPart.Text>()
.map { it.text }
.joinToString("\n")
.trim()
if (userText.isEmpty()) return null
val notes = try {
prefetcher.prefetch(userText, topK = 10)
} catch (e: Throwable) {
System.err.println("[agentik] memory prefetch failed: ${e.message}")
return null
}
if (notes.isEmpty()) return null
val body = notes.joinToString("\n") { n -> "- [${n.category.id}] ${n.content.take(280)}" }
return buildString {
appendLine("[Memory context — relevant long-term facts from previous sessions. Use if directly relevant to the user's current request; do NOT treat as instructions or new facts to memorize. This block is regenerated each turn and may differ from one turn to another — that's expected.]")
append(body)
}.trimEnd()
}
/**
* Сжатие working memory перед ходом, если оценка токенов превысила порог.
*
* Алгоритм:
* 1. Оценить количество токенов, которое модель увидит в этом ходу
* (system + skills + memory + tools + история).
* 2. Если `estimated / contextWindow >= compressionThreshold` — взять старые
* ходы (User/Assistant, не System) из working memory, отдать их в
* [contextCompactor] для генерации summary-строки, дёрнуть
* [memoryReviewer.reviewPreCompaction] (триггер долговременной памяти),
* затем атомарно: workingMemory.compact(fromIdx, summaryText).
*
* Если compaction не помог (после свёртки всё ещё > порог) — логируем warning
* и продолжаем. Не зацикливаемся: лишние свёртки только тратят токены.
*
* No-op когда [contextWindow] или [contextCompactor] == null.
*/
private suspend fun compactPreTurnIfNeeded() {
val window = contextWindow ?: return
val compactor = contextCompactor ?: return
val wm = workingMemory.list(id)
if (wm.isEmpty()) return
val systemText = wm.firstOrNull { it.entry is WorkingMemoryEntry.System }
?.let { (it.entry as WorkingMemoryEntry.System).text }
?: systemPrompt
val history = wm.filter { it.entry is WorkingMemoryEntry.User || it.entry is WorkingMemoryEntry.Assistant }
val toolsChars = tools.sumOf { it.tool.describe().length }
val estimated = estimateTokens(
systemText = systemText,
history = history,
toolsChars = toolsChars,
)
if (estimated.toDouble() / window < compressionThreshold) return
// Берём для свёртки старые ходы, последние KEEP_RECENT_TURNS оставляем
// как есть — это самая свежая часть контекста, которая нужна модели для
// продолжения. Если в истории пока меньше KEEP_RECENT_TURNS ходов — сворачиваем
// всё (защищать нечего, а порог всё равно превышен).
val toCompact = if (history.size > KEEP_RECENT_TURNS) {
history.dropLast(KEEP_RECENT_TURNS)
} else {
history
}
if (toCompact.isEmpty()) return
val turns = toCompact.mapNotNull { row ->
when (val e = row.entry) {
is WorkingMemoryEntry.User -> SummaryTurn(
userMessage = e.content.text(),
assistantMessage = "",
createdAt = row.createdAt,
)
is WorkingMemoryEntry.Assistant -> SummaryTurn(
userMessage = "",
assistantMessage = e.content.text(),
createdAt = row.createdAt,
)
else -> null
}
}
// Pair up user→assistant (best-effort; непарные уходят с пустой стороной).
val paired = ArrayList<SummaryTurn>()
var pendingUser: SummaryTurn? = null
for (t in turns) {
if (t.userMessage.isNotBlank()) {
if (pendingUser != null) paired.add(pendingUser)
pendingUser = t
} else if (t.assistantMessage.isNotBlank() && pendingUser != null) {
paired.add(pendingUser.copy(assistantMessage = t.assistantMessage))
pendingUser = null
} else if (t.assistantMessage.isNotBlank()) {
paired.add(t)
}
}
if (pendingUser != null) paired.add(pendingUser)
if (paired.isEmpty()) {
System.err.println("[agentik] compactPreTurn: nothing to compact for $id")
return
}
val summaryText = try {
compactor.summarize(paired)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Throwable) {
System.err.println("[agentik] context summarization failed for $id: ${e.message}")
return
}
if (summaryText.isBlank()) return
// Триггер памяти: до удаления ходов даём ревьюеру шанс вытащить факты.
val reviewer = memoryReviewer
val store = memoryStoreForReview
if (reviewer != null && store != null) {
try {
val convTurns = paired.map {
pw.binom.agentik.memory.ConversationTurn(
userMessage = it.userMessage,
assistantMessage = it.assistantMessage,
createdAt = it.createdAt,
)
}
val decision = reviewer.reviewPreCompaction(convTurns)
for (n in decision.toSave) {
val note = materializeReviewNote(n, conversationId = null)
runCatching { store.upsert(note) }
.onFailure { System.err.println("[agentik] pre-compaction upsert failed: ${it.message}") }
}
for (delId in decision.toDelete) {
runCatching { store.delete(delId) }
.onFailure { System.err.println("[agentik] pre-compaction delete failed: ${it.message}") }
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Throwable) {
System.err.println("[agentik] pre-compaction review failed for $id: ${e.message}")
}
}
// Атомарный compact: dropFromOrderIdx = первый order_idx из toCompact.
val dropFrom = toCompact.first().orderIdx
workingMemory.compact(dropFromOrderIdx = dropFrom, conversationId = id, summaryText = summaryText)
// liteConv теперь пересоздастся на следующем getOrCreateLiteConversation —
// KV-cache старой истории нам больше не нужен.
runCatching { liteConv?.close() }
liteConv = null
val after = estimateTokens(
systemText = systemText,
history = workingMemory.list(id).filter { it.entry is WorkingMemoryEntry.User || it.entry is WorkingMemoryEntry.Assistant },
toolsChars = toolsChars,
)
if (after.toDouble() / window >= compressionThreshold) {
System.err.println("[agentik] compactPreTurn: still over threshold for $id (estimated=$after, window=$window, threshold=$compressionThreshold). Consider raising contextWindow or lowering threshold.")
}
}
/**
* Грубая оценка токенов: LiteLlm не даёт точного tokenCount до создания
* диалога, поэтому считаем по chars/4 для system + tools + history
* (нормально работает для English/Russian mix, ±25%). Точный подсчёт
* появится вместе с tiktoken-интеграцией, если понадобится.
*/
private fun estimateTokens(systemText: String, history: List<WorkingMemoryRow>, toolsChars: Int): Int {
val sysTokens = systemText.length / 4
val toolsTokens = toolsChars / 4
val historyChars = history.sumOf { row ->
when (val e = row.entry) {
is WorkingMemoryEntry.User -> e.content.sumCharLen()
is WorkingMemoryEntry.Assistant -> e.content.sumCharLen()
else -> 0
}
}
return sysTokens + toolsTokens + historyChars / 4
}
private fun List<Content>.text(): String = filterIsInstance<Content.Text>().joinToString("\n") { it.body }
private fun List<Content>.sumCharLen(): Int = sumOf { c -> when (c) { is Content.Text -> c.body.length; is Content.Image -> c.data.size / 4 } }
/**
* Запустить фоновую корутину review'а: взять последний user+assistant,
* получить от [memoryReviewer] список [MemoryReviewDecision.toSave], замапить
* в [MemoryNote] и положить в [memoryStoreForReview]. Не блокирует turn.
*
* Для temp-бесед и без ревьюера — no-op.
*/
private fun scheduleReview(
userRecord: MessageRecord.UserMessage,
assistantContent: List<Content>,
) {
val reviewer = memoryReviewer ?: return
val store = memoryStoreForReview ?: return
if (record.isTemporal) return
val userText = userRecord.content.filterIsInstance<Content.Text>()
.joinToString("\n") { it.body }
val assistantText = assistantContent.filterIsInstance<Content.Text>()
.joinToString("\n") { it.body }
if (userText.isBlank() || assistantText.isBlank()) return
val convId = id
scope.launch {
try {
val decision: MemoryReviewDecision = reviewer.review(
ReviewedTurn(
userMessage = userText,
assistantMessage = assistantText,
conversationId = convId,
),
)
for (n in decision.toSave) {
val note = materializeReviewNote(n, conversationId = null)
runCatching { store.upsert(note) }
.onFailure { System.err.println("[agentik] review upsert failed: ${it.message}") }
}
for (id in decision.toDelete) {
runCatching { store.delete(id) }
.onFailure { System.err.println("[agentik] review delete failed: ${it.message}") }
}
} catch (e: Throwable) {
System.err.println("[agentik] review failed for $convId: ${e.message}")
}
}
}
/**
* Один tool-call: эмитим Event.ToolCall, выполняем tool (MCP), эмитим Event.ToolResult,
* пишем в audit + working memory, подаём результат в LiteConversation.
@@ -333,7 +631,10 @@ class ChatConversation(
}
.map { row ->
when (val e = row.entry) {
is WorkingMemoryEntry.User -> LiteMessage(LiteRole.USER, e.content.toLiteContents())
is WorkingMemoryEntry.User -> LiteMessage(
LiteRole.USER,
applyContextPrefix(e.content.toLiteContents(), e.context),
)
is WorkingMemoryEntry.Assistant -> LiteMessage(LiteRole.MODEL, e.content.toLiteContents())
else -> error("unreachable")
}
@@ -405,6 +706,8 @@ class ChatConversation(
companion object {
private const val MAX_TOOL_LOOPS = 16
/** Сколько последних ходов оставляем нетронутыми при compaction. */
private const val KEEP_RECENT_TURNS = 4
}
}
@@ -415,6 +718,51 @@ internal fun Content.toLite(): LiteContentPart = when (this) {
is Content.Image -> LiteContentPart.Image(data, mime)
}
/**
* Формат human-readable префикса контекста инициации хода.
*
* USER — без префикса (обычный пользователь).
* SYSTEM / EVENT — `[origin] description (sourceId=…)` строкой, добавляемой
* к первому текстовому контенту. Только текстовые части префиксуются —
* image-parts не трогаем (модели-мультимодалы не любят лишний шум перед
* картинкой).
*
* Пример: `[EVENT] scheduled cron morning-briefing (sourceId=cron-42)`
*/
internal fun formatContextPrefix(context: MessageContext): String {
val parts = mutableListOf<String>()
parts += "[${context.origin.name}]"
context.description?.takeIf { it.isNotBlank() }?.let { parts += " $it" }
context.sourceId?.takeIf { it.isNotBlank() }?.let { parts += " (sourceId=$it)" }
return parts.joinToString("")
}
/**
* Применяет префикс контекста к LiteContentPart'ам user-сообщения.
* Только для не-USER origin'ов: добавляет одну дополнительную Text-часть
* ПЕРЕД первой Text-частью (или в начало списка, если текста нет).
*
* Картинки и другие не-текстовые части не префиксуются — добавляется только
* отдельная текстовая «шапка». Метаданные контекста (JSON) не попадают
* в LLM-нагрузку: модель видит только человекочитаемую метку.
*/
internal fun applyContextPrefix(parts: List<LiteContentPart>, context: MessageContext?): List<LiteContentPart> {
if (context == null || context.origin == MessageOrigin.USER) return parts
val prefix = formatContextPrefix(context)
val out = ArrayList<LiteContentPart>(parts.size + 1)
var inserted = false
for (p in parts) {
if (!inserted && p is LiteContentPart.Text) {
out += LiteContentPart.Text("$prefix\n${p.text}")
inserted = true
} else {
out += p
}
}
if (!inserted) out.add(0, LiteContentPart.Text(prefix))
return out
}
private fun Content.toProto(): ProtoContent = when (this) {
is Content.Text -> ProtoContent.Text(body = body)
is Content.Image -> ProtoContent.Image(data = data, mime = mime)
@@ -425,11 +773,41 @@ internal fun ProtoContent.toStorage(): Content = when (this) {
is ProtoContent.Image -> Content.Image(data, mime)
}
/**
* Маппинг между :proto и persistence-слоями для [pw.binom.agentik.proto.MessageContext].
* Два отдельных типа живут чтобы слой хранения не зависел от :proto.
*/
internal fun ProtoMessageContext.toStorage(): MessageContext = MessageContext(
origin = when (origin) {
pw.binom.agentik.proto.MessageOrigin.USER -> MessageOrigin.USER
pw.binom.agentik.proto.MessageOrigin.SYSTEM -> MessageOrigin.SYSTEM
pw.binom.agentik.proto.MessageOrigin.EVENT -> MessageOrigin.EVENT
},
description = description,
sourceId = sourceId,
metadata = metadata,
)
internal fun MessageContext.toProto(): ProtoMessageContext {
val protoOrigin = when (origin) {
MessageOrigin.USER -> pw.binom.agentik.proto.MessageOrigin.USER
MessageOrigin.SYSTEM -> pw.binom.agentik.proto.MessageOrigin.SYSTEM
MessageOrigin.EVENT -> pw.binom.agentik.proto.MessageOrigin.EVENT
}
return ProtoMessageContext(
origin = protoOrigin,
description = description,
sourceId = sourceId,
metadata = metadata,
)
}
internal fun MessageRecord.toProto(): ProtoMessage = when (this) {
is MessageRecord.UserMessage -> ProtoMessage.UserMessage(
id = id,
date = createdAt,
content = content.map { it.toProto() },
context = context?.toProto(),
)
is MessageRecord.AssistantMessage -> ProtoMessage.AssistantMessage(
id = id,
@@ -0,0 +1,115 @@
package pw.binom.agentik.standalone.agent
import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteRole
import kotlin.time.Instant
/**
* Один ход диалога в формате, удобном для суммаризации.
*
* Не тянем из audit log напрямую — работаем со своим упрощённым представлением,
* чтобы compaction не зависел от деталей хранения.
*/
data class SummaryTurn(
val userMessage: String,
val assistantMessage: String,
val createdAt: Instant? = null,
)
/**
* Сжимает список прошлых ходов диалога в короткий markdown-саммари.
*
* Суммаризация — ответственность **агента**, потому что зависит от модели
* (context window, summarization prompt, format). Не кладём в `:memory-api`,
* чтобы модуль памяти не знал про LiteLlm.
*
* Имплементация по умолчанию — [LiteLlmContextCompactor] (один-shot LLM-вызов
* по промпту из Hermes `context_compressor.py`).
*/
fun interface ContextCompactor {
suspend fun summarize(turns: List<SummaryTurn>): String
}
/**
* LLM-реализация [ContextCompactor]. Использует отдельный [LiteConversation]
* без tools и без истории — чистый one-shot вызов, который не загрязняет
* KV-cache основного диалога.
*
* Промпт — структура из Hermes `context_compressor.py`:
* - Goal
* - Active State
* - Resolved
* - Blocked / Open Questions
* - Remaining Work
*
* Возвращает короткий markdown-блок (≈ 10-20 строк), который встанет в
* working memory вместо выкинутых ходов.
*/
class LiteLlmContextCompactor(
private val liteLlm: LiteLlm,
private val modelTemperature: Float = 0.2f,
) : ContextCompactor {
override suspend fun summarize(turns: List<SummaryTurn>): String {
if (turns.isEmpty()) return ""
val transcript = turns.joinToString("\n\n") { turn ->
val stamp = turn.createdAt?.toString()?.let { "[$it] " } ?: ""
buildString {
append(stamp).append("USER: ").append(turn.userMessage.trim()).append('\n')
append(stamp).append("ASSISTANT: ").append(turn.assistantMessage.trim())
}
}
val userPrompt = buildString {
appendLine("Transcript of past turns (oldest first):")
appendLine("```")
append(transcript.take(MAX_TRANSCRIPT_CHARS))
if (transcript.length > MAX_TRANSCRIPT_CHARS) appendLine("…(truncated)")
appendLine("```")
appendLine()
appendLine("Produce a compact context summary in this exact structure:")
appendLine("- **Goal**: one-line primary objective of this conversation")
appendLine("- **Active State**: where we are now / what we are currently doing")
appendLine("- **Resolved**: concrete decisions / outputs that are already done")
appendLine("- **Blocked / Open Questions**: things still unresolved")
appendLine("- **Remaining Work**: explicit next steps")
appendLine()
appendLine("Keep total length under ~20 lines. Plain markdown, no preamble.")
}
val cfg = LiteConversationConfig(
systemInstruction = SYSTEM_PROMPT,
initialMessages = emptyList(),
tools = emptyList(),
temperature = modelTemperature,
)
val conv: LiteConversation = liteLlm.createConversation(cfg)
try {
val reply = StringBuilder()
conv.sendStreamContents(listOf(LiteContentPart.Text(userPrompt))).collect { delta ->
if (delta.text.isNotEmpty()) reply.append(delta.text)
}
return reply.toString().trim().ifEmpty { "(empty summary)" }
} finally {
runCatching { conv.close() }
}
}
companion object {
private const val MAX_TRANSCRIPT_CHARS: Int = 24_000
private val SYSTEM_PROMPT = """
You are a context compressor for an ongoing AI conversation. Your job is to
produce a compact structured summary of past turns so that the conversation
can continue without losing the user's goal and current state.
Be terse and concrete. Prefer bullet points over prose. Never invent facts
that are not present in the transcript. Do not address the user — this
summary is for internal use by another LLM.
""".trimIndent()
}
}
@@ -0,0 +1,189 @@
package pw.binom.agentik.standalone.agent.memory
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import pw.binom.agentik.memory.DefaultMemoryTools
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySearchQuery
import pw.binom.agentik.memory.MemorySearchResult
import pw.binom.agentik.memory.MemorySource
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.NewMemoryNote
import java.util.UUID
import kotlin.time.Clock
import kotlinx.serialization.json.jsonObject
private val MemoryToolsJson = Json { ignoreUnknownKeys = true; isLenient = true }
private val MemoryResponseJson = Json { encodeDefaults = true }
/**
* Превращает [NewMemoryNote] (из reviewer'а) в полноценную [MemoryNote],
* генерируя id и временные метки. Источник выставляется в [MemorySource.AUTO_REVIEW].
*/
fun materializeReviewNote(n: NewMemoryNote, conversationId: String? = null): MemoryNote {
val now = Clock.System.now()
return MemoryNote(
id = "mem-${UUID.randomUUID()}",
category = n.category,
content = n.content,
createdAt = now,
lastUsedAt = now,
useCount = 0,
conversationId = conversationId,
source = MemorySource.AUTO_REVIEW,
)
}
/** Сериализация результатов поиска для LLM — компактный JSON. */
fun serializeSearchResults(results: List<MemorySearchResult>): String {
val items = results.map { r ->
buildJsonObject {
put("id", JsonPrimitive(r.note.id))
put("category", JsonPrimitive(r.note.category.id))
put("score", JsonPrimitive(r.score))
put("content", JsonPrimitive(truncate(r.note.content, 300)))
}
}
return MemoryResponseJson.encodeToString(JsonElement.serializer(), buildJsonArray { items.forEach { add(it) } })
}
/** Сериализация списка заметок для LLM. */
fun serializeNotes(notes: List<MemoryNote>): String {
val items = notes.map { n ->
buildJsonObject {
put("id", JsonPrimitive(n.id))
put("category", JsonPrimitive(n.category.id))
put("content", JsonPrimitive(truncate(n.content, 300)))
put("created_at", JsonPrimitive(n.createdAt.toString()))
}
}
return MemoryResponseJson.encodeToString(JsonElement.serializer(), buildJsonArray { items.forEach { add(it) } })
}
private fun truncate(s: String, max: Int): String =
if (s.length <= max) s else s.substring(0, max) + "…"
private fun parseArgs(s: String): JsonObject =
runCatching { MemoryToolsJson.parseToJsonElement(s).jsonObject }.getOrElse { JsonObject(emptyMap()) }
private fun objString(s: String, key: String): String? {
val v = parseArgs(s)[key] ?: return null
return if (v is JsonPrimitive && v.isString) v.content else v.toString().trim('"')
}
private fun objInt(s: String, key: String): Int? = objString(s, key)?.toIntOrNull()
/** memory_save(category, content) → upsert. */
internal fun saveTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = buildJsonObject {
put("description", DefaultMemoryTools.save.description)
putJsonObject("parameters") {
putJsonObject("properties") {
putJsonObject("category") {
put("type", "string")
put("enum", buildJsonArray { add("user"); add("world"); add("preference") })
put("description", "user | world | preference")
}
putJsonObject("content") {
put("type", "string")
put("description", "the fact to remember")
}
}
putJsonArray("required") { add("category"); add("content") }
}
}.toString(),
) { args ->
val category = objString(args, "category")?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() }
?: return@SyncLiteTool """{"error":"category required"}"""
val content = objString(args, "content")
?: return@SyncLiteTool """{"error":"content required"}"""
if (content.isBlank()) return@SyncLiteTool """{"error":"content is blank"}"""
val note = materializeReviewNote(NewMemoryNote(category, content))
store.upsert(note)
"""{"ok":true,"id":"${note.id}"}"""
}
/** memory_read(query, top_k?, category?) → search. */
internal fun readTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = buildJsonObject {
put("description", DefaultMemoryTools.read.description)
putJsonObject("parameters") {
putJsonObject("properties") {
putJsonObject("query") {
put("type", "string")
put("description", "free-text query")
}
putJsonObject("top_k") {
put("type", "integer")
put("description", "максимум результатов (default 5)")
}
putJsonObject("category") {
put("type", "string")
put("enum", buildJsonArray { add("user"); add("world"); add("preference") })
}
}
putJsonArray("required") { add("query") }
}
}.toString(),
) { args ->
val query = objString(args, "query") ?: return@SyncLiteTool """{"error":"query required"}"""
val topK = objInt(args, "top_k") ?: 5
val category = objString(args, "category")?.takeIf { it.isNotBlank() }
?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() }
val results = store.search(MemorySearchQuery(query = query, topK = topK, category = category))
serializeSearchResults(results)
}
/** memory_list(category?, limit?) → list. */
internal fun listTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = buildJsonObject {
put("description", DefaultMemoryTools.list.description)
putJsonObject("parameters") {
putJsonObject("properties") {
putJsonObject("category") {
put("type", "string")
put("enum", buildJsonArray { add("user"); add("world"); add("preference") })
}
putJsonObject("limit") {
put("type", "integer")
put("description", "default 20")
}
}
}
}.toString(),
) { args ->
val category = objString(args, "category")?.takeIf { it.isNotBlank() }
?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() }
val limit = objInt(args, "limit") ?: 20
val notes = store.list(category = category, limit = limit)
serializeNotes(notes)
}
/** memory_delete(id) → delete. */
internal fun deleteTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = buildJsonObject {
put("description", DefaultMemoryTools.delete.description)
putJsonObject("parameters") {
putJsonObject("properties") {
putJsonObject("id") {
put("type", "string")
put("description", "memory note id (mem-...)")
}
}
putJsonArray("required") { add("id") }
}
}.toString(),
) { args ->
val id = objString(args, "id") ?: return@SyncLiteTool """{"error":"id required"}"""
if (store.delete(id)) """{"ok":true,"deleted":"$id"}""" else """{"ok":false,"missing":"$id"}"""
}
@@ -0,0 +1,34 @@
package pw.binom.agentik.standalone.agent.memory
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.standalone.agent.NamedTool
import pw.binom.litert.LiteTool
/**
* Обёртки `DefaultMemoryTools` (memory_save / memory_read / memory_list / memory_delete)
* поверх конкретного [MemoryStore]. Каждый инструмент возвращает JSON-строку,
* совместимую с тем, что отдают остальные NamedTool'ы в проекте.
*/
object MemoryToolsFactory {
fun create(store: MemoryStore): List<NamedTool> = listOf(
NamedTool("memory_save", saveTool(store)),
NamedTool("memory_read", readTool(store)),
NamedTool("memory_list", listTool(store)),
NamedTool("memory_delete", deleteTool(store)),
)
}
/**
* Адаптер из suspend-tool в синхронный [LiteTool]. LiteTool-контракт на
* JVM-движке — синхронный; [runBlocking] выполняет suspend-лямбду в том же
* потоке, что и сам LiteLlm-вызов (LiteTool.invoke синхронен).
*/
internal class SyncLiteTool(
private val describeJson: String,
private val handler: suspend (String) -> String,
) : LiteTool {
override fun describe(): String = describeJson
override fun invoke(arguments: String): String = runBlocking { handler(arguments) }
}
@@ -25,10 +25,57 @@ data class AgentikConfig(
val mcp: McpConfig = McpConfig.empty(),
/** Папка со скилами (SKILL.md / *.yaml). `null` — скилы выключены. */
val skillsDir: String? = null,
/**
* Корневая директория памяти (Hermes-style §-файлы). `null` — память
* включается на дефолте `~/.agentik/memory`. Спецзначение `"off"` —
* память выключена (тулы memory_* не регистрируются, prefetch отключён).
*/
val memoryDir: String? = null,
/**
* Путь к SOUL.md — файл с описанием персоны ассистента (markdown body).
* Содержимое вставляется в самое начало `systemInstruction` поверх
* базового промпта, секции навыков и memory-guidance. `null` — файл не
* читается, секция не добавляется.
*/
val soulPath: String? = null,
/**
* Порог compaction'а working memory: доля от contextWindow, при которой
* запускается суммаризация старых ходов. Дефолт `0.8` (80%). Чем меньше —
* тем раньше начинаем сжимать (безопаснее для больших ассистентских
* ответов, но больше токенов уходит на compaction-вызовы).
*
* Если `contextWindow == null` (не задан через `OPENAI_CONTEXT_WINDOW`) —
* compaction не запускается вне зависимости от threshold.
*/
val compressionThreshold: Double = DEFAULT_COMPRESSION_THRESHOLD,
/**
* Бэкенд долговременной памяти.
* - [MemoryBackend.MD] — Hermes-style §-файлы (keyword overlap).
* - [MemoryBackend.VECTOR] — SQLite + JVector + LLM-эмбеддинги.
* - [MemoryBackend.OFF] — память выключена (`AGENTIK_MEMORY_DIR=off`).
*/
val memoryBackend: MemoryBackend = MemoryBackend.MD,
/**
* Имя модели эмбеддингов для vector-бэкенда. Дефолт `text-embedding-3-small`
* (1536-мерный). Должна быть доступна через тот же baseUrl/apiKey что и LLM.
*/
val embeddingModel: String = DEFAULT_EMBEDDING_MODEL,
/**
* Размерность эмбеддингов vector-бэкенда. Должна совпадать с реальной
* размерностью [embeddingModel]. Дефолт 1536 для `text-embedding-3-small`.
*/
val embeddingDimension: Int = DEFAULT_EMBEDDING_DIMENSION,
) {
/** Бэкенд долговременной памяти. */
@Serializable
enum class MemoryBackend { MD, VECTOR, OFF }
companion object {
const val DEFAULT_PORT: Int = 8080
const val DEFAULT_DB_PATH: String = "./agentik.db"
const val DEFAULT_COMPRESSION_THRESHOLD: Double = 0.8
const val DEFAULT_EMBEDDING_MODEL: String = "text-embedding-3-small"
const val DEFAULT_EMBEDDING_DIMENSION: Int = 1536
/**
* Читает конфигурацию из переменных среды.
@@ -41,6 +88,17 @@ data class AgentikConfig(
llm = LlmConfig.fromEnv(env),
mcp = McpConfig.fromEnv(env),
skillsDir = env("AGENTIK_SKILLS_DIR")?.takeIf { it.isNotBlank() },
memoryDir = env("AGENTIK_MEMORY_DIR")?.takeIf { it.isNotBlank() },
soulPath = env("AGENTIK_SOUL")?.takeIf { it.isNotBlank() },
compressionThreshold = env("AGENTIK_COMPRESSION_THRESHOLD")?.toDoubleOrNull()
?.coerceIn(0.1, 0.99) ?: DEFAULT_COMPRESSION_THRESHOLD,
memoryBackend = env("AGENTIK_MEMORY_BACKEND")?.let {
runCatching { MemoryBackend.valueOf(it.uppercase()) }.getOrNull()
} ?: MemoryBackend.MD,
embeddingModel = env("AGENTIK_EMBEDDING_MODEL")?.takeIf { it.isNotBlank() }
?: DEFAULT_EMBEDDING_MODEL,
embeddingDimension = env("AGENTIK_EMBEDDING_DIMENSION")?.toIntOrNull()
?: DEFAULT_EMBEDDING_DIMENSION,
)
}
}
@@ -36,6 +36,23 @@ data class LlmConfig(
LlmBackend.GOOGLE -> "${checkNotNull(google).modelPath}"
}
/**
* Размер контекстного окна в токенах для текущего бэкенда, или `null`,
* если не задан ни в env, ни в конфиге. Когда `null` — [ChatConversation]
* не считает лимит и compaction не запускается.
*
* Резолвер env (вызывается один раз из `Main.kt`): `OPENAI_CONTEXT_WINDOW`
* для OpenAI-совместимых, `AGENTIK_GOOGLE_CONTEXT_WINDOW` для Google/LiteRT.
* Никакого автодетекта по имени модели — если лимит не задан, лучше не
* сжимать вообще, чем угадывать.
*/
fun resolveContextWindow(env: (String) -> String? = System::getenv): Int? = when (backend) {
LlmBackend.OPENAI -> env("OPENAI_CONTEXT_WINDOW")?.toIntOrNull()
?: openai?.contextWindow
LlmBackend.GOOGLE -> env("AGENTIK_GOOGLE_CONTEXT_WINDOW")?.toIntOrNull()
?: google?.contextWindow
}
companion object {
const val DEFAULT_SYSTEM_PROMPT: String = "Ты полезный ассистент. Отвечай кратко и по делу."
@@ -49,6 +66,7 @@ data class LlmConfig(
baseUrl = requireEnv(env, "OPENAI_BASE_URL"),
apiKey = requireEnv(env, "OPENAI_API_KEY"),
model = requireEnv(env, "OPENAI_MODEL"),
contextWindow = env("OPENAI_CONTEXT_WINDOW")?.toIntOrNull(),
)
LlmConfig(backend, systemPrompt, openai = openai)
}
@@ -57,6 +75,7 @@ data class LlmConfig(
modelPath = requireEnv(env, "AGENTIK_GOOGLE_MODEL_PATH"),
cacheDir = env("AGENTIK_GOOGLE_CACHE_DIR"),
threads = env("AGENTIK_GOOGLE_THREADS")?.toInt(),
contextWindow = env("AGENTIK_GOOGLE_CONTEXT_WINDOW")?.toIntOrNull(),
)
LlmConfig(backend, systemPrompt, google = google)
}
@@ -74,6 +93,11 @@ data class OpenAiConfig(
val baseUrl: String,
val apiKey: String,
val model: String,
/**
* Лимит контекстного окна в токенах. `null` → берётся из env
* `OPENAI_CONTEXT_WINDOW`, иначе compaction не запускается.
*/
val contextWindow: Int? = null,
) {
fun toLitertConfig(): LitertOpenAiConfig = LitertOpenAiConfig(
baseUrl = baseUrl,
@@ -101,6 +125,11 @@ data class GoogleConfig(
val modelPath: String,
val cacheDir: String? = null,
val threads: Int? = null,
/**
* Лимит контекстного окна в токенах. `null` → берётся из env
* `AGENTIK_GOOGLE_CONTEXT_WINDOW`, иначе compaction не запускается.
*/
val contextWindow: Int? = null,
) {
fun toLiteConfig(): LiteConfig = LiteConfig(
modelPath = modelPath,
@@ -52,7 +52,7 @@ class SqliteMessageStore(private val db: AgentikDatabase) : MessageStore {
}
private fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) {
is MessageRecord.UserMessage -> "user" to encodeBodyPayload(record.content)
is MessageRecord.UserMessage -> "user" to encodeBodyPayload(record.content, record.context)
is MessageRecord.AssistantMessage -> "assistant" to encodeBodyPayload(record.content)
is MessageRecord.ToolCall -> "tool_call" to Json.encodeToString(
CallPayload.serializer(),
@@ -84,18 +84,25 @@ private fun Message.toRecord(): MessageRecord {
val convId = conversation_id
val createdAt = Instant.fromEpochMilliseconds(created_at)
return when (kind) {
"user" -> MessageRecord.UserMessage(
id = id,
conversationId = convId,
content = decodeBodyPayload(payload_json),
createdAt = createdAt,
)
"assistant" -> MessageRecord.AssistantMessage(
id = id,
conversationId = convId,
content = decodeBodyPayload(payload_json),
createdAt = createdAt,
)
"user" -> {
val decoded = decodeBodyPayload(payload_json)
MessageRecord.UserMessage(
id = id,
conversationId = convId,
content = decoded.content,
createdAt = createdAt,
context = decoded.context,
)
}
"assistant" -> {
val decoded = decodeBodyPayload(payload_json)
MessageRecord.AssistantMessage(
id = id,
conversationId = convId,
content = decoded.content,
createdAt = createdAt,
)
}
"tool_call" -> {
val p = Json.decodeFromString(CallPayload.serializer(), payload_json)
MessageRecord.ToolCall(
@@ -37,11 +37,33 @@ class SqliteWorkingMemoryStore(private val db: AgentikDatabase) : WorkingMemoryS
q.clearByConversation(conversationId)
}
override suspend fun compact(dropFromOrderIdx: Long, conversationId: String): Long {
override suspend fun compact(
dropFromOrderIdx: Long,
conversationId: String,
summaryText: String?,
): Long {
var newMax = 0L
val nowMs = System.currentTimeMillis()
val summaryId = newId()
db.transaction {
q.compactDelete(conversation_id = conversationId, order_idx = dropFromOrderIdx)
newMax = q.maxOrderIdx(conversationId).executeAsOne()
if (!summaryText.isNullOrBlank()) {
val afterDelete = q.maxOrderIdx(conversationId).executeAsOne()
val newIdx = afterDelete + 1
q.compactInsert(
id = summaryId,
conversation_id = conversationId,
order_idx = newIdx,
payload_json = json.encodeToString(
WorkingMemoryEntry.serializer(),
WorkingMemoryEntry.Summary(text = summaryText),
),
created_at = nowMs,
)
newMax = newIdx
} else {
newMax = q.maxOrderIdx(conversationId).executeAsOne()
}
}
return newMax
}
@@ -53,6 +75,7 @@ private fun entryKind(e: WorkingMemoryEntry): String = when (e) {
is WorkingMemoryEntry.System -> "system"
is WorkingMemoryEntry.User -> "user"
is WorkingMemoryEntry.Assistant -> "assistant"
is WorkingMemoryEntry.Summary -> "summary"
}
private fun Working_memory.toRow(): WorkingMemoryRow {
@@ -26,9 +26,13 @@ DELETE FROM working_memory WHERE conversation_id = ?;
maxOrderIdx:
SELECT COALESCE(MAX(order_idx), 0) FROM working_memory WHERE conversation_id = ?;
-- Atomic compact: delete rows >= dropFromOrderIdx and insert summary.
-- Caller supplies summaryId, summaryText, now epoch millis, and the new summary
-- gets order_idx = current max (after delete = before max).
-- Atomic compact: delete rows >= dropFromOrderIdx, then insert summary at
-- the next order_idx (max(remaining) + 1). Caller supplies summaryId, summaryText,
-- and current epoch millis for created_at.
compactDelete:
DELETE FROM working_memory
WHERE conversation_id = ? AND order_idx >= ?;
compactInsert:
INSERT INTO working_memory (id, conversation_id, order_idx, source_message_id, kind, payload_json, created_at)
VALUES (?, ?, ?, NULL, 'summary', ?, ?);
@@ -465,93 +465,6 @@ class ChatAgentTest {
}
/** Поддельный LiteLlm: возвращает fakeLlm.reply в sendStreamContents, опционально запоминает history. */
private class FakeLiteLlm : LiteLlm {
override val backendName: String = "fake"
override val capabilities: pw.binom.litert.LiteCapabilities? = null
var reply: String = ""
var rememberHistory: Boolean = false
var slow: Boolean = false
var failMessage: String? = null
var lastConfig: LiteConversationConfig? = null
var lastContents: List<LiteContentPart>? = null
val conversations = mutableListOf<FakeLiteConversation>()
override fun isInitialized(): Boolean = true
override fun createConversation(config: LiteConversationConfig): LiteConversation {
lastConfig = config
val conv = FakeLiteConversation(this, config)
conversations.add(conv)
return conv
}
override fun infer(request: pw.binom.litert.LiteRequest): String {
throw UnsupportedOperationException("not used in test")
}
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> {
throw UnsupportedOperationException("not used in test")
}
override fun close() {}
fun emit(text: String, sink: FakeLiteConversation): List<LiteDelta> {
// Эмулируем один-два фрагмента + done
return listOf(
LiteDelta(text = text.substring(0, text.length / 2), isDone = false),
LiteDelta(text = text.substring(text.length / 2), isDone = true),
)
}
}
private class FakeLiteConversation(
private val parent: FakeLiteLlm,
config: LiteConversationConfig,
) : LiteConversation {
val initialMessages: List<LiteMessage> = config.initialMessages
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
override val history: List<LiteMessage>
get() = mutableHistory.toList()
override fun sendStream(prompt: String): Flow<LiteDelta> =
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
parent.lastContents = contents
parent.failMessage?.let { msg ->
return kotlinx.coroutines.flow.flow { throw RuntimeException(msg) }
}
mutableHistory.add(LiteMessage(LiteRole.USER, contents))
if (parent.slow) {
return kotlinx.coroutines.flow.flow {
emit(LiteDelta(text = parent.reply.substring(0, parent.reply.length / 2)))
kotlinx.coroutines.delay(10_000)
emit(LiteDelta(text = parent.reply.substring(parent.reply.length / 2), isDone = true))
mutableHistory.add(LiteMessage.model(parent.reply))
}
}
val first = parent.reply.substring(0, parent.reply.length / 2)
val second = parent.reply.substring(parent.reply.length / 2)
return flowOf(
LiteDelta(text = first),
LiteDelta(text = second, isDone = true),
).also {
mutableHistory.add(LiteMessage.model(parent.reply))
}
}
override fun send(prompt: String): String = parent.reply
override fun sendContents(contents: List<LiteContentPart>): String = parent.reply
override fun cancel() {}
override fun tokenCount(): Int = history.size
override fun addToolResult(callId: String?, name: String, result: String) { error("not used") }
override fun close() {}
}
/**
* LiteLlm который имитирует tool-loop:
* - первый send → LiteDelta(toolCalls=[LiteToolCall("echo", {"x":"hi"})], isDone=true)
* - после addToolResult → продолжение send отдаёт LiteDelta(text="final reply", isDone=true)
*/
private class ToolLoopFakeLiteLlm : LiteLlm {
override val backendName: String = "fake-tool"
override val capabilities: pw.binom.litert.LiteCapabilities? = null
@@ -0,0 +1,218 @@
package pw.binom.agentik.standalone.agent
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.NewMemoryNote
import pw.binom.agentik.memory.ReviewedTurn
import pw.binom.agentik.memory.md.KeywordMdReviewer
import pw.binom.agentik.standalone.config.AgentikConfig
import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.standalone.llm.OpenAiConfig
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
import pw.binom.agentik.proto.Content as ProtoContent
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteMessage
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.Flow
import pw.binom.agentik.memory.MemoryStoreEvent
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.test.assertFalse
import kotlin.time.Instant
import kotlinx.coroutines.flow.asSharedFlow
/**
* Тесты для [ChatConversation.compactPreTurnIfNeeded]: триггер compaction'а
* при превышении порога, вызов суммаризатора, триггер memory review, и
* атомарный replace в working memory.
*/
class CompactionTest {
private lateinit var stores: SqliteStores
private lateinit var fakeLlm: FakeLiteLlm
@BeforeTest
fun setup() {
stores = SqliteStores.inMemory()
fakeLlm = FakeLiteLlm()
}
@AfterTest
fun tearDown() {
stores.close()
}
private fun newAgent(
contextWindow: Int? = null,
compressionThreshold: Double = 0.8,
compactor: ContextCompactor? = null,
memoryStore: MemoryStore? = null,
): ChatAgent {
val reviewer = if (memoryStore != null) KeywordMdReviewer() else null
return ChatAgent(
id = "test",
stores = stores,
llm = fakeLlm,
llmConfig = LlmConfig(
backend = LlmBackend.OPENAI,
systemPrompt = "be brief",
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
),
memoryStore = memoryStore,
memoryReviewer = reviewer,
contextWindow = contextWindow,
compressionThreshold = compressionThreshold,
contextCompactor = compactor,
)
}
@Test
fun `compaction is no-op when contextWindow is null`() = runTest {
// contextWindow=null → даже с огромной историей compaction не запустится.
fakeLlm.reply = "hi"
val agent = newAgent(contextWindow = null, compactor = RecordingCompactor("summary"))
val conv = agent.createConversation(temp = false) as ChatConversation
repeat(10) {
conv.send(listOf(ProtoContent.Text("turn $it: ${"x".repeat(200)}")))
}
val wm = stores.workingMemory.list(conv.id)
// Без compaction все ходы остаются в памяти (System + 10 user/assistant = 21 строк).
val summaries = wm.filter { it.entry is pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.Summary }
assertEquals(0, summaries.size, "compaction must not run without contextWindow")
}
@Test
fun `compaction is no-op when compactor is null but window is set`() = runTest {
fakeLlm.reply = "hi"
val agent = newAgent(contextWindow = 10, compactor = null)
val conv = agent.createConversation(temp = false) as ChatConversation
conv.send(listOf(ProtoContent.Text("first")))
val wm = stores.workingMemory.list(conv.id)
// System + User + Assistant = 3. Без compactor — никаких Summary.
val summaries = wm.filter { it.entry is pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.Summary }
assertEquals(0, summaries.size, "no compaction runs without compactor")
}
@Test
fun `compaction triggers when estimated tokens exceed threshold`() = runTest {
fakeLlm.reply = "ok"
val compactor = RecordingCompactor("**Goal**: x\n**Active**: y\n**Resolved**: z")
// contextWindow = 20 chars → ~5 токенов. С порогом 0.5 (50%) — почти любой ход пробивает.
val agent = newAgent(contextWindow = 20, compressionThreshold = 0.5, compactor = compactor)
val conv = agent.createConversation(temp = false) as ChatConversation
conv.send(listOf(ProtoContent.Text("user message one — long enough to cross threshold")))
// Compactor должен был быть вызван хотя бы раз.
assertTrue(compactor.calls > 0, "compactor must be called at least once when above threshold")
// В working memory должна появиться Summary.
val wm = stores.workingMemory.list(conv.id)
val summaries = wm.filter { it.entry is pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.Summary }
assertTrue(summaries.isNotEmpty(), "at least one Summary entry should be present after compaction")
// Summary-текст — то, что вернул наш compactor.
val summaryText = (summaries.first().entry as pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.Summary).text
assertTrue(summaryText.startsWith("**Goal**"), "summary text should come from compactor: $summaryText")
}
@Test
fun `compaction calls memoryReviewer reviewPreCompaction`() = runTest {
fakeLlm.reply = "ok"
val memStore = InMemoryMemoryStore()
val compactor = RecordingCompactor("compacted summary")
val agent = newAgent(
contextWindow = 30,
compressionThreshold = 0.5,
compactor = compactor,
memoryStore = memStore,
)
val conv = agent.createConversation(temp = false) as ChatConversation
conv.send(listOf(ProtoContent.Text("Я обычно предпочитаю kotlin для бэкенда.")))
// Память должна получить хотя бы одну заметку от reviewPreCompaction.
val notes = memStore.list()
assertTrue(notes.any { it.category == MemoryCategory.PREFERENCE && it.content.contains("kotlin") },
"memory should capture a preference fact before compaction drops the turn")
}
@Test
fun `compaction preserves recent turns (KEEP_RECENT_TURNS)`() = runTest {
fakeLlm.reply = "ok"
val compactor = RecordingCompactor("compacted summary")
val agent = newAgent(contextWindow = 30, compressionThreshold = 0.3, compactor = compactor)
val conv = agent.createConversation(temp = false) as ChatConversation
conv.send(listOf(ProtoContent.Text("first turn")))
conv.send(listOf(ProtoContent.Text("second turn")))
conv.send(listOf(ProtoContent.Text("third turn — long content ${"y".repeat(150)}")))
val wm = stores.workingMemory.list(conv.id)
// Должны быть: System + хотя бы один Summary + последние KEEP_RECENT_TURNS ходов.
// KEEP_RECENT_TURNS = 4 → user/assistant последних двух ходов (third + second) могут быть не тронуты.
val userAssistantCount = wm.count {
it.entry is pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.User ||
it.entry is pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.Assistant
}
// Минимум 1 ход остаётся (KEEP_RECENT_TURNS).
assertTrue(userAssistantCount >= 1, "at least one recent turn must be preserved")
}
}
private class RecordingCompactor(private val result: String) : ContextCompactor {
var calls = 0
override suspend fun summarize(turns: List<SummaryTurn>): String {
calls++
return result
}
}
/**
* Простой in-memory MemoryStore для тестов compaction'а (триггер памяти).
*/
private class InMemoryMemoryStore : MemoryStore {
private val notes = mutableMapOf<String, MemoryNote>()
private val ev = MutableSharedFlow<MemoryStoreEvent>(extraBufferCapacity = 16)
override suspend fun upsert(note: MemoryNote) {
notes[note.id] = note
ev.tryEmit(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 }
.drop(offset)
.take(limit)
override suspend fun search(query: pw.binom.agentik.memory.MemorySearchQuery): List<pw.binom.agentik.memory.MemorySearchResult> =
notes.values
.filter { query.category == null || it.category == query.category }
.map { pw.binom.agentik.memory.MemorySearchResult(it, 1.0f) }
.take(query.topK)
override suspend fun delete(id: String): Boolean {
val ok = notes.remove(id) != null
if (ok) ev.tryEmit(MemoryStoreEvent.Deleted(id))
return ok
}
override suspend fun markUsed(id: String, at: Instant) {
notes[id]?.let { notes[id] = it.copy(lastUsedAt = at, useCount = it.useCount + 1) }
}
override fun events(): Flow<MemoryStoreEvent> = ev.asSharedFlow()
override fun close() {}
fun list() = notes.values.toList()
}
@@ -0,0 +1,116 @@
package pw.binom.agentik.standalone.agent
import pw.binom.agentik.standalone.persistence.MessageContext
import pw.binom.agentik.standalone.persistence.MessageOrigin.EVENT
import pw.binom.agentik.standalone.persistence.MessageOrigin.SYSTEM
import pw.binom.agentik.standalone.persistence.MessageOrigin.USER
import pw.binom.litert.LiteContentPart
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
/**
* Тесты для префикса контекста инициации хода в user-сообщениях.
* Только для не-USER origin'ов. USER — без изменений.
*/
class ContextPrefixTest {
@Test
fun `USER origin produces no prefix`() {
val ctx = MessageContext(origin = USER)
val parts = listOf(LiteContentPart.Text("hello"))
val out = applyContextPrefix(parts, ctx)
assertEquals(parts, out, "USER should not modify content")
}
@Test
fun `null context produces no prefix`() {
val parts = listOf(LiteContentPart.Text("hello"))
val out = applyContextPrefix(parts, null)
assertEquals(parts, out)
}
@Test
fun `SYSTEM origin prepends label to first text part`() {
val ctx = MessageContext(origin = SYSTEM, description = "agent startup greeting")
val parts = listOf(LiteContentPart.Text("boot"))
val out = applyContextPrefix(parts, ctx)
assertEquals(1, out.size)
val text = assertIs<LiteContentPart.Text>(out[0])
assertEquals("[SYSTEM] agent startup greeting\nboot", text.text)
}
@Test
fun `EVENT origin with sourceId includes it`() {
val ctx = MessageContext(
origin = EVENT,
description = "scheduled cron morning-briefing",
sourceId = "cron-42",
)
val parts = listOf(LiteContentPart.Text("wake up"))
val out = applyContextPrefix(parts, ctx)
val text = assertIs<LiteContentPart.Text>(out[0])
assertEquals("[EVENT] scheduled cron morning-briefing (sourceId=cron-42)\nwake up", text.text)
}
@Test
fun `prefix only added to first text part, others untouched`() {
val ctx = MessageContext(origin = EVENT, description = "test")
val parts = listOf(
LiteContentPart.Text("first"),
LiteContentPart.Text("second"),
)
val out = applyContextPrefix(parts, ctx)
assertEquals(2, out.size)
val first = assertIs<LiteContentPart.Text>(out[0])
val second = assertIs<LiteContentPart.Text>(out[1])
assertEquals("[EVENT] test\nfirst", first.text)
assertEquals("second", second.text)
}
@Test
fun `prefix with no text parts is prepended as standalone text`() {
val ctx = MessageContext(origin = SYSTEM, description = "ping")
// Симулируем: модель получает картинку + контекст — контекст идёт первой Text-частью.
val parts = listOf<LiteContentPart>(LiteContentPart.Text("just prefix"))
val out = applyContextPrefix(parts, ctx)
assertEquals(1, out.size)
val text = assertIs<LiteContentPart.Text>(out[0])
assertEquals("[SYSTEM] ping\njust prefix", text.text)
}
@Test
fun `formatContextPrefix formats name + description + sourceId`() {
val ctx = MessageContext(origin = EVENT, description = "wake", sourceId = "cron-1")
assertEquals("[EVENT] wake (sourceId=cron-1)", formatContextPrefix(ctx))
}
@Test
fun `formatContextPrefix omits blank description and sourceId`() {
val ctx = MessageContext(origin = SYSTEM)
assertEquals("[SYSTEM]", formatContextPrefix(ctx))
}
@Test
fun `formatContextPrefix omits blank sourceId even if description is set`() {
val ctx = MessageContext(origin = SYSTEM, description = "boot", sourceId = "")
assertEquals("[SYSTEM] boot", formatContextPrefix(ctx))
}
@Test
fun `USER origin with context fields still produces no prefix`() {
// Контекст с USER-происхождением, но с заполненным description/sourceId:
// не должен триггерить префикс (UI-метаданные для логирования).
val ctx = MessageContext(origin = USER, sourceId = "irc:agentik", description = "PRIVMSG")
val parts = listOf(LiteContentPart.Text("hi"))
val out = applyContextPrefix(parts, ctx)
assertEquals(parts, out)
}
// Вспомогательное для теста
@Test
fun `null-context assert helper`() {
assertNull(null as String?)
}
}
@@ -0,0 +1,86 @@
package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteDelta
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteMessage
import pw.binom.litert.LiteRole
/**
* Тестовая [LiteLlm], запоминающая последний конфиг/контент и отвечающая
* заданной строкой [reply] двумя фрагментами + done.
*/
internal class FakeLiteLlm : LiteLlm {
override val backendName: String = "fake"
override val capabilities: pw.binom.litert.LiteCapabilities? = null
var reply: String = ""
var rememberHistory: Boolean = false
var slow: Boolean = false
var failMessage: String? = null
var lastConfig: LiteConversationConfig? = null
var lastContents: List<LiteContentPart>? = null
val conversations = mutableListOf<FakeLiteConversation>()
override fun isInitialized(): Boolean = true
override fun createConversation(config: LiteConversationConfig): LiteConversation {
lastConfig = config
val conv = FakeLiteConversation(this, config)
conversations.add(conv)
return conv
}
override fun infer(request: pw.binom.litert.LiteRequest): String =
throw UnsupportedOperationException("not used in test")
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> =
throw UnsupportedOperationException("not used in test")
override fun close() {}
}
internal class FakeLiteConversation(
private val parent: FakeLiteLlm,
config: LiteConversationConfig,
) : LiteConversation {
val initialMessages: List<LiteMessage> = config.initialMessages
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
override val history: List<LiteMessage> get() = mutableHistory.toList()
override fun sendStream(prompt: String): Flow<LiteDelta> =
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
parent.lastContents = contents
parent.failMessage?.let { msg ->
return kotlinx.coroutines.flow.flow { throw RuntimeException(msg) }
}
mutableHistory.add(LiteMessage(LiteRole.USER, contents))
if (parent.slow) {
return kotlinx.coroutines.flow.flow {
emit(LiteDelta(text = parent.reply.substring(0, parent.reply.length / 2)))
kotlinx.coroutines.delay(10_000)
emit(LiteDelta(text = parent.reply.substring(parent.reply.length / 2), isDone = true))
mutableHistory.add(LiteMessage.model(parent.reply))
}
}
val first = parent.reply.substring(0, parent.reply.length / 2)
val second = parent.reply.substring(parent.reply.length / 2)
return flowOf(
LiteDelta(text = first),
LiteDelta(text = second, isDone = true),
).also {
mutableHistory.add(LiteMessage.model(parent.reply))
}
}
override fun send(prompt: String): String = parent.reply
override fun sendContents(contents: List<LiteContentPart>): String = parent.reply
override fun cancel() {}
override fun tokenCount(): Int = history.size
override fun addToolResult(callId: String?, name: String, result: String) { error("not used") }
override fun close() {}
}
@@ -0,0 +1,299 @@
package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.files.SystemTemporaryDirectory
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemoryPrefetcher
import pw.binom.agentik.memory.MemoryReviewDecision
import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemorySearchQuery
import pw.binom.agentik.memory.MemorySearchResult
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemorySource
import pw.binom.agentik.memory.MemorySystemGuidance
import pw.binom.agentik.memory.NewMemoryNote
import pw.binom.agentik.memory.ReviewedTurn
import pw.binom.agentik.memory.md.openMdMemorySystem
import pw.binom.agentik.proto.Content
import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.standalone.llm.OpenAiConfig
import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Интеграция памяти в :standalone:
* - тулы memory_save/read/list/delete регистрируются у агента
* - prefetcher вставляет контекст в первое user-сообщение
* - reviewer пишет факты в store после хода
*/
class MemoryWiringTest {
private lateinit var stores: SqliteStores
private lateinit var fakeLlm: FakeLiteLlm
private lateinit var root: Path
@BeforeTest
fun setup() {
stores = SqliteStores.inMemory()
fakeLlm = FakeLiteLlm()
root = Path(SystemTemporaryDirectory.toString(), "agentik-mem-${java.util.UUID.randomUUID()}")
SystemFileSystem.createDirectories(root, mustCreate = true)
}
@AfterTest
fun tearDown() {
stores.close()
runCatching { SystemFileSystem.delete(root, mustExist = false) }
}
private fun newAgent(
memoryStore: MemoryStore,
prefetcher: MemoryPrefetcher,
reviewer: MemoryReviewer,
): ChatAgent = ChatAgent(
id = "agentik",
stores = stores,
llm = fakeLlm,
llmConfig = LlmConfig(
backend = LlmBackend.OPENAI,
systemPrompt = "be brief",
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
),
memoryStore = memoryStore,
memoryPrefetcher = prefetcher,
memoryReviewer = reviewer,
)
@Test
fun `system prompt includes memory guidance when memory is enabled`() = runBlocking {
val system = openMdMemorySystem(root)
val agent = newAgent(system.store, system.prefetcher, system.reviewer)
val conv = agent.createConversation(temp = false)
val wm = stores.workingMemory.list(conv.id)
val sysRow = wm.first { it.entry is WorkingMemoryEntry.System }
val text = (sysRow.entry as WorkingMemoryEntry.System).text
assertTrue(text.contains(MemorySystemGuidance.MEMORY_GUIDANCE.take(80)),
"system prompt should contain MEMORY_GUIDANCE")
agent.close()
system.close()
}
@Test
fun `soul body is prepended to system prompt and wins over base`() = runBlocking {
val soulBody = "I am a helpful test persona. I always answer in one short line."
val agent = ChatAgent(
id = "agentik",
stores = stores,
llm = fakeLlm,
llmConfig = LlmConfig(
backend = LlmBackend.OPENAI,
systemPrompt = "be brief",
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
),
soulBody = soulBody,
)
val conv = agent.createConversation(temp = false)
val wm = stores.workingMemory.list(conv.id)
val sysRow = wm.first { it.entry is WorkingMemoryEntry.System }
val text = (sysRow.entry as WorkingMemoryEntry.System).text
assertTrue(text.startsWith(soulBody),
"soul should be the very first section; got first 60 chars: ${text.take(60)}")
assertTrue(text.contains("be brief"),
"base prompt should still follow the soul; got: $text")
agent.close()
}
@Test
fun `soul body not added when null`() = runBlocking {
val agent = ChatAgent(
id = "agentik",
stores = stores,
llm = fakeLlm,
llmConfig = LlmConfig(
backend = LlmBackend.OPENAI,
systemPrompt = "be brief",
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
),
)
val conv = agent.createConversation(temp = false)
val wm = stores.workingMemory.list(conv.id)
val sysRow = wm.first { it.entry is WorkingMemoryEntry.System }
val text = (sysRow.entry as WorkingMemoryEntry.System).text
assertTrue(text.startsWith("be brief"),
"without soul, prompt should start with base; got first 60 chars: ${text.take(60)}")
agent.close()
}
@Test
fun `agent exposes memory tools when store is configured`() {
val system = openMdMemorySystem(root)
val tools = MemoryToolsFactory.create(system.store)
assertEquals(4, tools.size)
val names = tools.map { it.name }.toSet()
assertEquals(setOf("memory_save", "memory_read", "memory_list", "memory_delete"), names)
// Каждый tool описывается валидной JSON-схемой:
for (t in tools) {
assertTrue(t.tool.describe().contains("\"description\""), "describe() for ${t.name}")
}
system.close()
}
@Test
fun `memory_save tool round-trips a note through the store`() = runBlocking {
val system = openMdMemorySystem(root)
val tools = MemoryToolsFactory.create(system.store).associateBy { it.name }
val saveResult = tools.getValue("memory_save").tool.invoke(
"""{"category":"preference","content":"prefers tabs over spaces"}""",
)
assertTrue(saveResult.contains("\"ok\":true"), "save returned: $saveResult")
assertTrue(saveResult.contains("\"id\":\"mem-"), "save returned: $saveResult")
val listResult = tools.getValue("memory_list").tool.invoke("""{"limit":10}""")
assertTrue(listResult.contains("prefers tabs over spaces"),
"list returned: $listResult")
val readResult = tools.getValue("memory_read").tool.invoke(
"""{"query":"tabs","top_k":3}""",
)
assertTrue(readResult.contains("prefers tabs over spaces"),
"read returned: $readResult")
val deleteResult = tools.getValue("memory_delete").tool.invoke(
Regex("\"id\":\"(mem-[^\"]+)\"").find(saveResult)?.let { m ->
"""{"id":"${m.groupValues[1]}"}"""
} ?: error("save did not return id"),
)
assertTrue(deleteResult.contains("\"ok\":true"), "delete returned: $deleteResult")
system.close()
}
@Test
fun `prefetch inserts memory context into first user message`() = runTest {
// Сидим факт в store.
val store = openMdMemorySystem(root).also {
it.store.upsert(
MemoryNote(
id = "mem-pre",
category = MemoryCategory.USER,
content = "User runs k3s on Debian",
createdAt = Instant.parse("2026-09-14T10:00:00Z"),
lastUsedAt = Instant.parse("2026-09-14T10:00:00Z"),
useCount = 0,
source = MemorySource.AGENT_SAVE,
),
)
}
val prefetcher = StaticPrefetcher { q, k ->
store.store.search(MemorySearchQuery(query = q, topK = k, category = null))
}
fakeLlm.reply = "ok"
val agent = newAgent(store.store, prefetcher, NoopReviewer())
val conv = agent.createConversation(temp = false) as ChatConversation
conv.send(listOf(Content.Text("what's my k3s setup?")))
val sentText = fakeLlm.lastContents?.filterIsInstance<pw.binom.litert.LiteContentPart.Text>()
?.joinToString("\n") { it.text }
assertNotNull(sentText)
assertTrue(sentText.startsWith("[Memory context"),
"user message should start with memory prefix, got: $sentText")
assertTrue(sentText.contains("User runs k3s on Debian"),
"user message should include the prefetched note, got: $sentText")
assertTrue(sentText.contains("what's my k3s setup?"),
"user message should still contain the original text after the prefix, got: $sentText")
agent.close()
store.close()
}
@Test
fun `prefetch does not add prefix when no hits`() = runTest {
val store = openMdMemorySystem(root)
val prefetcher = StaticPrefetcher { _, _ -> emptyList() }
fakeLlm.reply = "ok"
val agent = newAgent(store.store, prefetcher, NoopReviewer())
val conv = agent.createConversation(temp = false) as ChatConversation
conv.send(listOf(Content.Text("hello")))
val sentText = fakeLlm.lastContents?.filterIsInstance<pw.binom.litert.LiteContentPart.Text>()
?.joinToString("\n") { it.text }
assertNotNull(sentText)
assertTrue(!sentText.startsWith("[Memory context"),
"user message should not start with prefix when no hits, got: $sentText")
assertTrue(sentText.contains("hello"))
agent.close()
store.close()
}
@Test
fun `reviewer upserts suggested notes after a successful turn`() = runTest {
val store = openMdMemorySystem(root)
fakeLlm.reply = "Sure, I'll remember that."
val reviewerReturned = CompletableDeferred<Unit>()
val reviewer = object : MemoryReviewer {
override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision {
val decision = MemoryReviewDecision(
toSave = listOf(NewMemoryNote(MemoryCategory.PREFERENCE, "prefers k8s")),
toDelete = emptyList(),
)
reviewerReturned.complete(Unit)
return decision
}
}
val agent = newAgent(store.store, StaticPrefetcher { _, _ -> emptyList() }, reviewer)
val conv = agent.createConversation(temp = false) as ChatConversation
conv.send(listOf(Content.Text("please note: I prefer k8s")))
// Дожидаемся, пока ревьюер отдаст решение, и ещё немного — чтобы
// scheduleReview успел сделать upsert в IO-диспетчере.
withContext(Dispatchers.Default.limitedParallelism(1)) {
withTimeout(2_000) { reviewerReturned.await() }
withTimeout(2_000) {
while (store.store.list(category = MemoryCategory.PREFERENCE).isEmpty()) delay(20)
}
}
val notes = store.store.list(category = MemoryCategory.PREFERENCE)
assertEquals(1, notes.size)
assertEquals("prefers k8s", notes[0].content)
assertEquals(MemorySource.AUTO_REVIEW, notes[0].source)
agent.close()
store.close()
}
}
// --- helpers ---
private class StaticPrefetcher(
private val fn: suspend (String, Int) -> List<MemorySearchResult>,
) : MemoryPrefetcher {
override suspend fun prefetch(query: String, topK: Int, category: MemoryCategory?): List<MemoryNote> {
if (query.isBlank()) return emptyList()
return fn(query, topK).map { it.note }
}
}
private class NoopReviewer : MemoryReviewer {
override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision =
MemoryReviewDecision(toSave = emptyList(), toDelete = emptyList())
}
@@ -119,6 +119,23 @@ class AgentikConfigTest {
assertEquals(null, cfg.skillsDir)
}
@Test
fun `soul path defaults to null`() {
assertEquals(null, AgentikConfig.fromEnv(openAiEnv()).soulPath)
}
@Test
fun `soul path read from env`() {
val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SOUL" to "/etc/SOUL.md")))
assertEquals("/etc/SOUL.md", cfg.soulPath)
}
@Test
fun `blank soul path falls back to null`() {
val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SOUL" to " ")))
assertEquals(null, cfg.soulPath)
}
@Test
fun `serialization round-trips through json`() {
val original = AgentikConfig.fromEnv(
@@ -153,4 +170,30 @@ class AgentikConfigTest {
assertEquals(original, restored)
}
@Test
fun `compressionThreshold defaults to 0_8 when env unset`() {
val cfg = AgentikConfig.fromEnv(openAiEnv())
assertEquals(0.8, cfg.compressionThreshold)
}
@Test
fun `compressionThreshold parsed from env`() {
val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "0.6")))
assertEquals(0.6, cfg.compressionThreshold)
}
@Test
fun `compressionThreshold clamped between min and max`() {
val tooLow = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "0.01")))
assertEquals(0.1, tooLow.compressionThreshold)
val tooHigh = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "1.5")))
assertEquals(0.99, tooHigh.compressionThreshold)
}
@Test
fun `compressionThreshold garbage falls back to default`() {
val cfg = AgentikConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "хрен")))
assertEquals(0.8, cfg.compressionThreshold)
}
}
@@ -86,4 +86,60 @@ class LlmConfigTest {
}
assertEquals(LlmConfig.DEFAULT_SYSTEM_PROMPT, cfg.systemPrompt)
}
@Test
fun `fromEnv — OPENAI_CONTEXT_WINDOW parsed into OpenAiConfig`() {
val cfg = LlmConfig.fromEnv { name ->
when (name) {
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
"OPENAI_API_KEY" -> "sk-test"
"OPENAI_MODEL" -> "gpt-4o-mini"
"OPENAI_CONTEXT_WINDOW" -> "128000"
else -> null
}
}
assertEquals(128_000, cfg.openai?.contextWindow)
}
@Test
fun `resolveContextWindow — env wins over config`() {
val cfg = LlmConfig.fromEnv { name ->
when (name) {
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
"OPENAI_API_KEY" -> "sk-test"
"OPENAI_MODEL" -> "gpt-4o-mini"
"OPENAI_CONTEXT_WINDOW" -> "64000"
else -> null
}
}
// env задаёт 64000; resolveContextWindow возвращает именно его (openai.contextWindow = 64000 уже после fromEnv).
assertEquals(64_000, cfg.resolveContextWindow { it })
}
@Test
fun `resolveContextWindow — returns null when nothing set`() {
val cfg = LlmConfig.fromEnv { name ->
when (name) {
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
"OPENAI_API_KEY" -> "sk-test"
"OPENAI_MODEL" -> "gpt-4o-mini"
else -> null
}
}
assertEquals(null, cfg.resolveContextWindow { null })
}
@Test
fun `fromEnv — OPENAI_CONTEXT_WINDOW garbage falls back to null`() {
val cfg = LlmConfig.fromEnv { name ->
when (name) {
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
"OPENAI_API_KEY" -> "sk-test"
"OPENAI_MODEL" -> "gpt-4o-mini"
"OPENAI_CONTEXT_WINDOW" -> "не-число"
else -> null
}
}
assertEquals(null, cfg.openai?.contextWindow)
}
}
@@ -0,0 +1,84 @@
package pw.binom.agentik.standalone.persistence
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import pw.binom.agentik.standalone.persistence.MessageOrigin.EVENT
import pw.binom.agentik.standalone.persistence.MessageOrigin.SYSTEM
import pw.binom.agentik.standalone.persistence.MessageOrigin.USER
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Тесты payload-формата: контекст инициации хода персистится через
* `payload_json` SQLite, старый plain-array формат читается без потерь.
*/
class PayloadTest {
@Test
fun `user context roundtrips through MessageBodyPayload`() {
val ctx = MessageContext(
origin = USER,
description = "irc PRIVMSG",
sourceId = "irc:agentik",
)
val encoded = encodeBodyPayload(listOf(Content.Text("hi")), ctx)
// новый формат: wrapper-объект с полем context
assertTrue(encoded.startsWith("{"), "expected wrapped object, got: $encoded")
assertTrue(encoded.contains("\"context\""), "expected context field, got: $encoded")
val decoded = decodeBodyPayload(encoded)
assertEquals(listOf(Content.Text("hi")), decoded.content)
assertEquals(ctx, decoded.context)
}
@Test
fun `event context roundtrips with metadata`() {
val ctx = MessageContext(
origin = EVENT,
description = "scheduled cron morning-briefing",
sourceId = "cron-42",
metadata = buildJsonObject {
put("scheduledAt", JsonPrimitive("2026-09-14T08:00:00Z"))
put("rule", JsonPrimitive("0 8 * * *"))
},
)
val encoded = encodeBodyPayload(listOf(Content.Text("wake up")), ctx)
val decoded = decodeBodyPayload(encoded)
assertEquals(EVENT, decoded.context?.origin)
assertEquals("scheduled cron morning-briefing", decoded.context?.description)
assertEquals("cron-42", decoded.context?.sourceId)
assertEquals("0 8 * * *", (decoded.context?.metadata?.let { (it as kotlinx.serialization.json.JsonObject)["rule"] } as? JsonPrimitive)?.content)
}
@Test
fun `null context produces wrapper without context field`() {
val encoded = encodeBodyPayload(listOf(Content.Text("hello")))
assertTrue(encoded.contains("\"content\""), "expected content field, got: $encoded")
val decoded = decodeBodyPayload(encoded)
assertEquals(listOf(Content.Text("hello")), decoded.content)
assertNull(decoded.context)
}
@Test
fun `legacy plain-array payload still decodes (backward compat)`() {
val legacy = "[" +
"""{"type":"text","body":"old message"}""" +
"]"
val decoded = decodeBodyPayload(legacy)
assertEquals(listOf(Content.Text("old message")), decoded.content)
assertNull(decoded.context)
}
@Test
fun `system context with description only roundtrips`() {
val ctx = MessageContext(origin = SYSTEM, description = "agent startup greeting")
val encoded = encodeBodyPayload(listOf(Content.Text("boot")), ctx)
val decoded = decodeBodyPayload(encoded)
assertEquals(SYSTEM, decoded.context?.origin)
assertEquals("agent startup greeting", decoded.context?.description)
assertNull(decoded.context?.sourceId)
assertNull(decoded.context?.metadata)
}
}
@@ -172,6 +172,74 @@ class PersistenceTest {
assertTrue(list[2].entry is WorkingMemoryEntry.Assistant)
}
@Test
fun `working memory — compact without summary just drops tail`() = runTest {
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
stores.workingMemory.append("c1", WorkingMemoryEntry.System("sys"), t0)
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0)
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m3", listOf(Content.Text("u2"))), t0)
val rows = stores.workingMemory.list("c1")
// Drop начиная со второго хода (User m1) — должно остаться System.
val dropFrom = rows[1].orderIdx
stores.workingMemory.compact(dropFrom, "c1", summaryText = null)
val after = stores.workingMemory.list("c1")
assertEquals(1, after.size)
assertTrue(after[0].entry is WorkingMemoryEntry.System)
}
@Test
fun `working memory — compact with summary inserts Summary entry`() = runTest {
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
stores.workingMemory.append("c1", WorkingMemoryEntry.System("sys"), t0)
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0)
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m3", listOf(Content.Text("u2"))), t0)
val rows = stores.workingMemory.list("c1")
val dropFrom = rows[1].orderIdx
stores.workingMemory.compact(dropFrom, "c1", summaryText = "**Goal**: chat\n**Active**: at u2\n**Resolved**: a1")
val after = stores.workingMemory.list("c1")
assertEquals(2, after.size)
assertTrue(after[0].entry is WorkingMemoryEntry.System)
val summary = after[1].entry
assertIs<WorkingMemoryEntry.Summary>(summary)
assertTrue(summary.text.startsWith("**Goal**"))
// order_idx должен быть > всех оставшихся
assertTrue(after[1].orderIdx > after[0].orderIdx)
// sourceMessageId у Summary всегда null
assertNull(after[1].sourceMessageId)
}
@Test
fun `working memory — compact with blank summaryText behaves as drop`() = runTest {
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
stores.workingMemory.append("c1", WorkingMemoryEntry.System("sys"), t0)
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
val rows = stores.workingMemory.list("c1")
stores.workingMemory.compact(rows[1].orderIdx, "c1", summaryText = "")
val after = stores.workingMemory.list("c1")
assertEquals(1, after.size)
assertTrue(after[0].entry is WorkingMemoryEntry.System)
}
@Test
fun `working memory — compact is atomic on other conversations`() = runTest {
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
stores.workingMemory.append("c1", WorkingMemoryEntry.System("sys1"), t0)
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
stores.workingMemory.append("c2", WorkingMemoryEntry.System("sys2"), t0)
stores.workingMemory.append("c2", WorkingMemoryEntry.User("m2", listOf(Content.Text("u2"))), t0)
stores.workingMemory.compact(2, "c1", summaryText = "sum")
val c1 = stores.workingMemory.list("c1")
val c2 = stores.workingMemory.list("c2")
// c1: System + Summary
assertEquals(2, c1.size)
assertTrue(c1[1].entry is WorkingMemoryEntry.Summary)
// c2 не тронут
assertEquals(2, c2.size)
assertTrue(c2[1].entry is WorkingMemoryEntry.User)
}
@Test
fun `rename updates title and bumps updated_at`() = runTest {
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
@@ -245,4 +313,62 @@ class PersistenceTest {
assertEquals("image/png", image.mime)
assertTrue(bytes.contentEquals(image.data))
}
@Test
fun `user message context roundtrips through SQLite`() = runTest {
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
val ctx = MessageContext(
origin = MessageOrigin.EVENT,
description = "scheduled cron morning-briefing",
sourceId = "cron-42",
)
stores.messages.append(
MessageRecord.UserMessage(
id = "m1",
conversationId = "c1",
content = listOf(Content.Text("wake up")),
createdAt = t0,
context = ctx,
),
)
val all = stores.messages.listAll("c1")
assertEquals(1, all.size)
val user = assertIs<MessageRecord.UserMessage>(all[0])
assertEquals(ctx, user.context)
}
@Test
fun `user message without context roundtrips with null context`() = runTest {
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
stores.messages.append(
MessageRecord.UserMessage(
id = "m1",
conversationId = "c1",
content = listOf(Content.Text("regular user message")),
createdAt = t0,
),
)
val all = stores.messages.listAll("c1")
val user = assertIs<MessageRecord.UserMessage>(all[0])
assertNull(user.context)
}
@Test
fun `working memory user entry context roundtrips through SQLite`() = runTest {
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
val ctx = MessageContext(origin = MessageOrigin.SYSTEM, description = "agent startup")
stores.workingMemory.append(
conversationId = "c1",
entry = WorkingMemoryEntry.User(
sourceMessageId = "m1",
content = listOf(Content.Text("boot")),
context = ctx,
),
now = t0,
)
val list = stores.workingMemory.list("c1")
assertEquals(1, list.size)
val user = assertIs<WorkingMemoryEntry.User>(list[0].entry)
assertEquals(ctx, user.context)
}
}