Remove :storage-inmemory module, tests, and related code.
ci / JVM build + tests (push) Successful in 6m46s

This commit is contained in:
2026-09-23 03:48:38 +03:00
parent 84f5fd84f3
commit e447525059
105 changed files with 2732 additions and 1978 deletions
+17 -2
View File
@@ -22,7 +22,7 @@ val skipVectorMemory: Boolean =
// :standalone — единственный entry-point проекта, JVM-only по факту (один
// `jvm()` target). Чтобы не таскать лишние sourceSet'ы, ВСЁ живёт в
// commonMain + commonTest — даже зависимости, которые формально JVM-only
// (ktor-server-cio, litert-openai, storage-ksqlite, ...). KMP-плагин тут
// (ktor-server-cio, litert-openai, ...). KMP-плагин тут
// только ради бесшовного потребления KMP-зависимостей (`:proto`, `:server`,
// `:journal-api`, ...); компилируется всё ровно в одну JVM-таргет.
kotlin {
@@ -64,21 +64,36 @@ kotlin {
}
// SQLite-бэкенд (ksqlite JNI) — JVM-only по факту.
implementation(project(":storage-ksqlite"))
implementation(project(":journal-ksqlite"))
implementation(project(":context-ksqlite"))
implementation(project(":reflection-ksqlite"))
// ksqlite объявлен как `implementation` (не `api`) в каждом
// ksqlite-модуле, поэтому SqliteStores здесь использует
// SQLiteConnection напрямую — фиксируем зависимость явно.
implementation("pw.binom.db:ksqlite:0.1.2")
// Bounded-tail live event stream + per-event TTL.
implementation(project(":outbox-inmemory"))
// :agent-api — MutableAgent + Component + ToolProvider/SystemPromptProvider.
// ChatAgent реализует MutableAgent; компоненты (McpBridgeComponent и т.п.)
// добавляются через agent.install(...).
implementation(project(":agent-api"))
// LLM-toolsets + generic LLM-side tools (LlmReflector, SkillMiner,
// LlmMemoryReviewer, ContextCompactor, парсеры/промпты).
implementation(project(":agent-toolsets"))
implementation(project(":llm-tools"))
// Skill-подсистема как Component: тулы + SkillMiningComponent.
implementation(project(":skill-mining"))
// Generic MCP-bridge (McpConfig, McpRegistry, McpLiteToolAdapter).
implementation(project(":mcp-bridge"))
// litert-kmp: контракт (commonMain)
api(libs.litert.api)
// liteTool DSL (типизированные LiteTool через @Serializable args)
implementation(libs.litert.tools.kotlinx.serialization)
// litert-openai: JVM-реализация
implementation(libs.litert.openai)
// litert-google: встроенный LiteRT-LM движок, нужен только на runtime
@@ -13,7 +13,7 @@ import pw.binom.agentik.reflection.ReflectionStore
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.standalone.agent.ChatConversation
import pw.binom.agentik.llm.tools.LlmReflector
import pw.binom.agentik.llm.tools.SkillMiner
import pw.binom.agentik.skill.mining.SkillMiner
import pw.binom.agentik.standalone.agent.memory.Curator
import pw.binom.agentik.skills.SkillStore
import pw.binom.agentik.context.ContextStore
@@ -30,9 +30,9 @@ import pw.binom.agentik.standalone.config.AppConfig.MemoryBackend
import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.ModelDownloader
import pw.binom.agentik.mcp.bridge.McpRegistry
import pw.binom.agentik.storage.ksqlite.KsqliteStores
import pw.binom.agentik.standalone.persistence.SqliteStores
import java.io.File
import pw.binom.agentik.llm.tools.SkillMiner
import pw.binom.agentik.skill.mining.SkillMiner
/**
* standalone-контейнер agentik:
* - :server (proto): встраиваемый Ktor (CIO), порт AGENTIK_PORT (default 8080)
@@ -199,7 +199,7 @@ private fun runServer() {
}
val llm = config.llm.createLlm()
val sqliteStores = KsqliteStores.open(path = config.agent.dbPath)
val sqliteStores = SqliteStores.open(path = config.agent.dbPath)
val mcpRegistry = McpRegistry.fromConfig(config.mcp)
// Хранилище скилов: если skillsDir задан, читаем каталог + создаём
@@ -214,9 +214,9 @@ private fun runServer() {
// Skill mining: фоновый LLM-прогон, который находит переиспользуемые скилы,
// которые модель забыла сохранить через `skill_save`. Работает только когда
// есть куда писать (skillStore) и интервал > 0.
val skillMiner: pw.binom.agentik.llm.tools.SkillMiner? =
val skillMiner: pw.binom.agentik.skill.mining.SkillMiner? =
if (skillStore != null && config.skillMining.interval > 0) {
pw.binom.agentik.llm.tools.SkillMiner(
pw.binom.agentik.skill.mining.SkillMiner(
llm = llm,
maxTurns = config.skillMining.maxTurns,
)
@@ -369,7 +369,6 @@ private fun runServer() {
reflectionStore = sqliteStores.reflections,
llm = llm,
llmConfig = config.llm,
tools = mcpRegistry.namedTools,
skills = skills,
skillStore = skillStore,
memoryStore = memorySystem?.store,
@@ -382,7 +381,7 @@ private fun runServer() {
recentReflections = recentReflections,
reflector = reflector,
skillMiner = skillMiner,
)
).install(pw.binom.agentik.mcp.bridge.McpBridgeComponent(mcpRegistry))
// Куратор памяти: фоновая архивация stale-заметок. Поднимается до server'а,
// чтобы debug-эндпоинты могли его триггерить вручную.
@@ -17,7 +17,7 @@ import pw.binom.agentik.reflection.ReflectionStore
import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.context.ContextStore
import pw.binom.agentik.llm.tools.LlmReflector
import pw.binom.agentik.llm.tools.SkillMiner
import pw.binom.agentik.skill.mining.SkillMiner
import java.util.concurrent.atomic.AtomicLong
/**
@@ -1,12 +1,25 @@
package pw.binom.agentik.standalone.agent
import kotlin.time.Instant
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.agent.Component
import pw.binom.agentik.agent.ConversationAware
import pw.binom.agentik.agent.ConversationHandle
import pw.binom.agentik.agent.ConversationTurn
import pw.binom.agentik.agent.MutableAgent
import pw.binom.agentik.agent.SystemPromptProvider
import pw.binom.agentik.agent.ToolProvider
import pw.binom.agentik.llm.tools.ContextCompactor
import pw.binom.agentik.llm.tools.LlmReflector
import pw.binom.agentik.llm.tools.SkillMiner
import pw.binom.agentik.skill.mining.SkillMiner
import pw.binom.agentik.memory.MemoryPrefetcher
import pw.binom.agentik.memory.MemoryReviewer
import pw.binom.agentik.memory.MemorySystemGuidance
@@ -18,8 +31,10 @@ import pw.binom.agentik.journal.JournalStore
import pw.binom.agentik.outbox.OutboxStore
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.agent.memory.MemoryComponent
import pw.binom.agentik.standalone.agent.soul.SoulComponent
import pw.binom.agentik.skill.mining.SkillMiningComponent
import pw.binom.agentik.skill.mining.SkillMiningEvent
import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.agentik.journal.ConversationStore
@@ -30,16 +45,17 @@ import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.agentik.context.ContextStore
import pw.binom.agentik.toolsets.DisableToolsetTool
import pw.binom.agentik.toolsets.EnableToolsetTool
import pw.binom.agentik.toolsets.NamedTool
import pw.binom.litert.LiteTool
import pw.binom.agentik.toolsets.SystemPromptToolsetSection
import pw.binom.agentik.toolsets.ToolsetComponent
import pw.binom.agentik.toolsets.ToolsetContribution
import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
import pw.binom.agentik.toolsets.ToolsetRegistry
import pw.binom.litert.LiteLlm
/**
* Stateful [ProtoAgent] на базе SQLite (история + working memory) и
* [LiteLlm] (универсальный LLM-контракт).
* Stateful [MutableAgent] (= [ProtoAgent] + install/uninstall/component extension)
* на базе SQLite (история + working memory) и [LiteLlm] (универсальный LLM-контракт).
*
* Создаёт [ChatConversation] — те самые stateful диалоги, которые
* хранят свой собственный LiteConversation в ОЗУ (для скорости) и
@@ -47,6 +63,13 @@ import pw.binom.litert.LiteLlm
*
* Один [LiteLlm] шарится между всеми беседами агента.
*
* **External тулы (MCP, etc.)** приходят не через constructor, а через
* [Component.install]: типичный путь — `McpBridgeComponent` из `:mcp-bridge`
* добавляет [ToolProvider] в [toolProviders] при [install]. На каждом
* `send()` или создании беседы агент вызывает [collectTools] чтобы
* собрать свежий список тулов (= внешние через [toolProviders] + встроенные).
* Это позволяет добавлять/убирать MCP-серверы в runtime без рестарта.
*
* Если задан [skills], их каталог (имя + краткое описание) подмешивается в
* системный промпт, а в набор тулов добавляется встроенный `read_skill` для
* загрузки полного текста навыка по требованию.
@@ -57,7 +80,7 @@ import pw.binom.litert.LiteLlm
* `ChatConversation` настраивается prefetch (контекст в начале user-сообщения)
* и post-turn reviewer (извлечение фактов после каждого хода).
*/
class ChatAgent(
internal class ChatAgent(
override val id: String,
private val mutableConversationStore: MutableConversationStore,
private val messageStore: MutableJournalStore,
@@ -65,7 +88,6 @@ class ChatAgent(
private val reflectionStore: ReflectionStore,
private val llm: LiteLlm,
private val llmConfig: LlmConfig,
private val tools: List<NamedTool> = emptyList(),
private val skills: SkillCatalog = SkillCatalog.EMPTY,
/**
* Хранилище скилов для self-improvement (запись/архивирование). Если
@@ -76,6 +98,8 @@ class ChatAgent(
private val skillStore: pw.binom.agentik.skills.SkillStore? = null,
private val memoryStore: pw.binom.agentik.memory.MemoryStore? = null,
private val memoryPrefetcher: MemoryPrefetcher? = null,
internal val _backgroundEvents: BackgroundEventBus = BackgroundEventBus(),
internal val agentScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
private val memoryReviewer: MemoryReviewer? = null,
/**
* Тело SOUL.md — markdown-описание персоны. Вставляется в самое начало
@@ -117,83 +141,230 @@ class ChatAgent(
* секция в system prompt НЕ добавляется (полная невидимость per A1-α).
*/
private val toolsets: List<ToolsetContribution> = emptyList(),
) : ProtoAgent, AutoCloseable {
) : MutableAgent, AutoCloseable {
/**
* Test-only: регистрирует дополнительный tool в общий [toolsByName] ПОСЛЕ
* создания ChatAgent. Используется в тестах `interrupt mid-tool` для
* симуляции долгого tool-вызова, который можно прервать через interrupt().
* В production этот API НЕ используется — тулы статичны через конструктор.
* Провайдеры внешних/динамических тулов (MCP, etc.). Заполняются
* компонентами через [install]. На каждом [collectTools] агент
* спрашивает у всех провайдеров их список тулов.
*
* Изменение этого списка (через [install]/[uninstall]) сразу же отражается
* на tool-dispatch следующего turn'а любой беседы.
*/
internal fun registerToolForTest(name: String, tool: pw.binom.litert.LiteTool) {
toolsByName[name] = NamedTool(name = name, tool = tool)
}
override val toolProviders: MutableList<ToolProvider> = mutableListOf()
/**
* Реестр активных тулсетов — один на агента (per-agent state).
* `ToolsetRegistry` потокобезопасен (Mutex), поэтому shared across conversations.
* Провайдеры секций системного промпта. Заполняются компонентами
* через [install]. На каждом turn'е агент собирает секции через
* [SystemPromptProvider.getSection].
*/
override val systemProviders: MutableList<SystemPromptProvider> = mutableListOf()
/**
* Установленные компоненты. В [close] проходим в обратном порядке
* и дёргаем [Component.uninstall], чтобы каждый успел закрыть свои ресурсы.
*/
private val components: MutableList<Component> = mutableListOf()
/**
* Реестр активных тулсетов — один на агента (per-agent state). Раньше
* жил прямо в ChatAgent, теперь поднимается через [ToolsetComponent]
* (см. init-блок ниже). Доступ к реестру — через [toolsetDispatch].
*/
private val toolsetRegistry: ToolsetRegistry = ToolsetRegistry(toolsets)
/** Список активных тулов (включая enable/disable если есть тулсеты). */
private val enabledToolsetTools: List<NamedTool> = if (toolsets.isNotEmpty()) {
listOf(
NamedTool(EnableToolsetTool.NAME, EnableToolsetTool(toolsetRegistry).tool),
NamedTool(DisableToolsetTool.NAME, DisableToolsetTool(toolsetRegistry).tool),
)
} else emptyList()
/**
* Системный промпт: (soul, если задан) → база → секция навыков → секция памяти
* → секция self-reflection (слабые места) → секция тулсетов (если они заданы).
* Именно он сидируется в working memory и передаётся в [ChatConversation].
* → секция self-reflection (слабые места). Секция тулсетов теперь приходит
* динамически от [ToolsetComponent] через [systemProviders].
*/
private val systemPrompt: String = buildSystemPrompt(
private val baseSystemPrompt: String = buildSystemPrompt(
base = llmConfig.systemPrompt,
skills = skills,
memoryEnabled = memoryStore != null,
soulBody = soulBody,
reflections = recentReflections,
toolsetSection = if (toolsets.isNotEmpty()) {
SystemPromptToolsetSection.render(
active = emptyList(), // все по умолчанию неактивны
inactive = toolsets,
)
} else null,
)
/**
* Тулы, которые видит модель: внешние ([tools], обычно MCP) + встроенные:
* `read_skill` (если есть скилы), `skill_save`/`skill_delete` (если есть
* [skillStore]), `memory_*` (если подключена память),
* `enable_toolset`/`disable_toolset` (если заданы [toolsets]).
* MCP-тулы префиксованы `server__`, так что коллизий нет.
* Тестовый инжектор: позволяет `ChatAgentTest` подсунуть тул, который
* скриптованная LLM будет вызывать. В production-конфигурации всегда пуст —
* инструменты приходят через [toolProviders] (компоненты, [install]).
*/
private val allTools: List<NamedTool> = buildList {
addAll(tools)
addAll(enabledToolsetTools)
if (!skills.isEmpty) add(NamedTool(SkillReadTool.NAME, SkillReadTool(skills)))
if (skillStore != null) addAll(SkillToolsFactory.create(skillStore))
if (memoryStore != null) addAll(MemoryToolsFactory.create(memoryStore))
private val testTools: MutableList<LiteTool> = mutableListOf()
/**
* Собирает **актуальный** список тулов для диспетчеризации:
* внешние из [toolProviders] + testTools + встроенные (read_skill,
* skill_save, memory_*, toolset enable/disable).
* Вызывается **на каждый tool-call** через [toolsetDispatch] (поэтому без
* кэша — добавление компонента сразу видно в dispatch).
*
* Дедупликация не делается — если два провайдера вернут тул с одинаковым
* именем, второй в lookup'е победит первый. Это намеренно оставлено на
* ответственности компонентов (в типичной конфигурации каждый компонент
* отвечает за свой namespace-префикс, и коллизий нет).
*/
internal fun collectTools(): List<LiteTool> = buildList {
toolProviders.forEach { addAll(it.getTools(conversationId = "")) }
addAll(testTools)
// Skill-* тулы приходят через SkillComponent → ToolProvider.
// memory_* тулы приходят через MemoryComponent → ToolProvider.
}
private val toolsByName: MutableMap<String, NamedTool> = allTools.associateBy { it.name }.toMutableMap()
/**
* Тестовый хук: регистрирует [tool] под именем [name] в общем пуле.
* Используется только в `ChatAgentTest`. В production-конфигурации тулы
* приходят через [install] компонентов с [ToolProvider].
*/
internal fun addTestTool(tool: LiteTool) {
testTools.add(tool)
}
/**
* Диспетчер вызовов тулов с учётом тулсетов. Создаётся всегда — даже когда
* [toolsets] пустой (тогда работает как passthrough через baseDispatcher).
* Это позволяет ChatConversation.runTurn всегда идти через один путь,
* без ветвления «с тулсетами / без».
*
* `baseDispatcher` лениво пересобирает список тулов через [collectTools]
* — это значит добавление/удаление компонента в runtime сразу же видно
* в tool-dispatch следующего turn'а.
*/
private val toolsetDispatch: ToolsetDispatchPolicy = ToolsetDispatchPolicy(
registry = toolsetRegistry,
baseDispatcher = { name, args ->
val t = toolsByName[name]
val t = collectTools().firstOrNull { it.name == name }
?: error("unknown tool: $name")
t.tool.invoke(args)
t.invoke(args)
},
)
init {
// Поднимаем механику toolsets как обычный Component — теперь тулсеты
// могут быть добавлены/убраны в runtime через тот же [install] API.
if (toolsets.isNotEmpty()) install(ToolsetComponent(toolsets))
// Память: тулы + system-provider — отдельным компонентом.
if (memoryStore != null) install(MemoryComponent(memoryStore))
// Скиллы: тулы + system-provider + фоновый mining.
if (skillStore != null) install(
SkillMiningComponent(
store = skillStore,
llm = llm,
events = skillMiningEvents(),
scope = agentScope,
)
)
// Душа: первая секция системного промпта.
if (!soulBody.isNullOrBlank()) install(SoulComponent(soulBody))
}
// Системный промпт для беседы — статичная база [baseSystemPrompt] плюс
// динамические секции из [systemProviders] на момент turn'а.
// В ConversationLoop передаётся через [systemPromptResolver] лямбду,
// которая дёргается на каждом `sendStreamContents` — LiteRT v12 setter
// сам делает no-op, если значение эквивалентно.
private val systemPrompt: String = baseSystemPrompt
/**
* Runtime system prompt = база + динамические секции от `systemProviders`.
* Вызывается на каждом turn'е (через [systemPromptResolver]). Пустые секции
* пропускаются; компонент, у которого `getSection` вернул пустую строку,
* не появляется в промте.
*/
private fun skillMiningEvents(): kotlinx.coroutines.flow.Flow<SkillMiningEvent> =
merge(
_backgroundEvents.lifecycleEvents.filterIsInstance<ConversationLifecycleEvent.Closing>()
.map { SkillMiningEvent.ConversationClosing(it.conversationId) },
_backgroundEvents.compactionEvents.filterIsInstance<CompactionEvent.Triggered>()
.map { SkillMiningEvent.ConversationCompacted(it.conversationId) },
)
internal fun buildRuntimeSystemPrompt(): String {
// Секции от systemProviders идут ПЕРЕД базой — это позволяет компонентам
// типа SoulComponent поставить себя во главу угла. Если нужна секция
// после базы — у компонента должен быть какой-то признак "prepend: false"
// (сейчас все systemProviders считаются prepend-only).
if (systemProviders.isEmpty()) return systemPrompt
val sections = systemProviders.mapNotNull { p ->
val section = p.getSection(conversationId = "")
section.takeIf { it.isNotBlank() }
}
if (sections.isEmpty()) return systemPrompt
return sections.joinToString("\n\n") + "\n\n" + systemPrompt.trimEnd()
}
/** Прокидывает [component] в [MutableAgent] и запоминает для [close]. */
override fun install(component: Component): MutableAgent {
component.install(this)
components.add(component)
return this
}
/** Снимает [component]. Идемпотентно — повторный uninstall безопасен. */
override fun uninstall(component: Component): MutableAgent {
component.uninstall(this)
components.remove(component)
return this
}
/**
* Оповещает [ConversationAware] компоненты о новом разговоре. Вызывается
* из [ChatConversation] сразу после конструктора — компонент может
* подписаться на events и запустить фоновые задачи.
*/
override fun attachConversation(handle: ConversationHandle) {
components.filterIsInstance<ConversationAware>().forEach { it.attachConversation(handle) }
}
/** Оповещает [ConversationAware] компоненты о закрытии разговора. */
override fun detachConversation(handle: ConversationHandle) {
components.filterIsInstance<ConversationAware>().forEach { it.detachConversation(handle) }
}
/**
* Проекция ChatConversation → ConversationHandle. Даём per-conversation
* компонентам только минимальный view (id / isTemporal / recentTurns),
* чтобы они не лезли в чужие обязанности.
*
* [recentTurns] берёт последние [limit] реплик из working memory —
* это всё, что нужно [SkillMiner] / [LlmReflector] для добычи знаний.
*/
private fun ChatConversation.asHandle(): ConversationHandle = object : ConversationHandle {
override val id: String get() = this@asHandle.id
override val isTemporal: Boolean get() = this@asHandle.isTemporal
override suspend fun recentTurns(limit: Int): List<ConversationTurn> {
val rows = workingMemoryStore.list(this@asHandle.id)
val filtered = rows.map { it.entry }.filter {
it is pw.binom.agentik.context.WorkingMemoryEntry.User ||
it is pw.binom.agentik.context.WorkingMemoryEntry.Assistant
}
// Схлопываем User/Assistant в пары user+assistant.
// Если последним идёт User без пары — assistantMessage пустой.
val pairs = mutableListOf<ConversationTurn>()
var pendingUser: String? = null
for (entry in filtered.takeLast(limit * 2)) {
val text = when (entry) {
is pw.binom.agentik.context.WorkingMemoryEntry.User ->
entry.content.filterIsInstance<pw.binom.agentik.journal.Content.Text>()
.joinToString("\n") { it.body }
is pw.binom.agentik.context.WorkingMemoryEntry.Assistant ->
entry.content.filterIsInstance<pw.binom.agentik.journal.Content.Text>()
.joinToString("\n") { it.body }
else -> continue
}
when (entry) {
is pw.binom.agentik.context.WorkingMemoryEntry.User -> pendingUser = text
is pw.binom.agentik.context.WorkingMemoryEntry.Assistant -> {
pairs += ConversationTurn(userMessage = pendingUser.orEmpty(), assistantMessage = text)
pendingUser = null
}
}
}
pendingUser?.let { pairs += ConversationTurn(userMessage = it, assistantMessage = "") }
return pairs.takeLast(limit)
}
override fun close() = Unit
}
/**
* Единый канал всех событий агента — bounded tail с auto-TTL.
*
@@ -278,7 +449,9 @@ class ChatAgent(
eventStore = eventStore,
llm = llm,
systemPrompt = systemPrompt,
tools = allTools,
systemPromptResolver = { buildRuntimeSystemPrompt() },
toolsResolver = { collectTools() },
tools = collectTools(),
toolsetDispatch = toolsetDispatch,
memoryPrefetcher = memoryPrefetcher,
memoryReviewer = memoryReviewer,
@@ -293,6 +466,10 @@ class ChatAgent(
runBlocking {
liveLock.withLock { live[conv.id] = conv }
}
// Оповещаем ConversationAware компоненты (например, SkillMiningComponent)
// о новом разговоре — они могут подписаться на events и запустить
// фоновые задачи.
attachConversation(conv.asHandle())
runBlocking {
eventStore.append(
CommonEvent.Agent(
@@ -309,11 +486,13 @@ class ChatAgent(
val rec = mutableConversationStore.get(id) ?: return null
return newConversation(rec).also {
liveLock.withLock { live[id] = it }
attachConversation(it.asHandle())
}
}
override suspend fun deleteConversation(id: String): Boolean {
val conv = liveLock.withLock { live.remove(id) }
conv?.let { detachConversation(it.asHandle()) }
conv?.close()
val ok = mutableConversationStore.delete(id)
if (ok) {
@@ -344,7 +523,9 @@ class ChatAgent(
eventStore = eventStore,
llm = llm,
systemPrompt = systemPrompt,
tools = allTools,
systemPromptResolver = { buildRuntimeSystemPrompt() },
toolsResolver = { collectTools() },
tools = collectTools(),
toolsetDispatch = toolsetDispatch,
memoryPrefetcher = memoryPrefetcher,
memoryReviewer = memoryReviewer,
@@ -361,9 +542,17 @@ class ChatAgent(
runBlocking {
liveLock.withLock {
live.values.forEach { it.close() }
live.values.forEach { detachConversation(it.asHandle()) }
live.clear()
}
}
// Снимаем компоненты в обратном порядке, чтобы каждый успел закрыть свои ресурсы.
// Копия `components` нужна на случай если uninstall модифицирует список.
val snapshot = components.toList()
for (i in snapshot.indices.reversed()) {
runCatching { snapshot[i].uninstall(this) }
}
components.clear()
runCatching { llm.close() }
}
@@ -380,28 +569,17 @@ class ChatAgent(
*/
internal fun buildSystemPrompt(
base: String,
skills: SkillCatalog,
memoryEnabled: Boolean,
soulBody: String? = null,
reflections: List<Reflection> = emptyList(),
toolsetSection: 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 withMemory = if (memoryEnabled) trimmedBase + "\n\n" + MemorySystemGuidance.MEMORY_GUIDANCE else trimmedBase
val withReflections = if (reflections.isNotEmpty()) {
withMemory + "\n\n" + renderReflectionsSection(reflections)
} else {
withMemory
}
val withToolsets = if (!toolsetSection.isNullOrBlank()) {
withReflections + "\n\n" + toolsetSection
} else {
withReflections
}
val trimmedSoul = soulBody?.trim()
return if (!trimmedSoul.isNullOrEmpty()) trimmedSoul + "\n\n" + withToolsets else withToolsets
return withReflections
}
/**
@@ -34,7 +34,7 @@ internal class CompactionCoordinator(
private val log = KotlinLogging.logger {}
private val toolsCharsCached: Int by lazy(LazyThreadSafetyMode.PUBLICATION) {
state.tools.sumOf { it.tool.describe().length }
state.tools.sumOf { it.describe().length }
}
suspend fun compactPreTurnIfNeeded(): Boolean = compactPreTurn(force = false)
@@ -147,7 +147,14 @@ internal class CompactionCoordinator(
val dropFrom = toCompact.first().orderIdx
workingMemory.compact(dropFromOrderIdx = dropFrom, conversationId = state.id, summaryText = summaryText)
state.liteConvRef.getAndSet(null)?.let { runCatching { it.close() } }
// После компакта пересборка LiteConversation не нужна: litert ≥13
// умеет replaceHistory() — синхронизируем движковое состояние с актуальной
// working memory, не теряя LiteRT-LM session. На OpenAI replaceHistory тоже
// лучше (stateless — просто подмена списка).
state.liteConvRef.get()?.let { lc ->
val newHistory = buildHistoryLiteMessages(excludeUserSourceId = null)
runCatching { lc.replaceHistory(newHistory) }
}
val after = estimateTokens(
systemText = systemText,
@@ -189,7 +196,29 @@ internal class CompactionCoordinator(
): LiteConversation {
state.liteConvRef.get()?.let { return it }
val pastTurns: List<LiteMessage> = if (state.isTemporal) emptyList() else workingMemory.list(state.id)
val pastTurns = buildHistoryLiteMessages(excludeUserSourceId)
val capped = if (pastTurns.size > MAX_SEEDED_MESSAGES) pastTurns.takeLast(MAX_SEEDED_MESSAGES) else pastTurns
val config = LiteConversationConfig(
systemInstruction = systemPrompt.takeIf { it.isNotBlank() },
initialMessages = capped,
tools = state.tools,
)
return liteLlm.createConversation(config).also { state.liteConvRef.set(it) }
}
/**
* Перевод [WorkingMemoryRow] в формат [LiteMessage] для LiteConversation.
* Используется и при первом create, и при [LiteConversation.replaceHistory]
* после компакта (см. [compactPreTurn]).
*/
private suspend fun buildHistoryLiteMessages(
excludeUserSourceId: String?,
): List<LiteMessage> {
if (state.isTemporal) return emptyList()
return workingMemory.list(state.id)
.filter { row ->
val isRelevant = row.entry is WorkingMemoryEntry.User
|| row.entry is WorkingMemoryEntry.Assistant
@@ -199,7 +228,7 @@ internal class CompactionCoordinator(
}
.mapNotNull { row ->
val e: WorkingMemoryEntry = row.entry
val msg: LiteMessage? = when (e) {
when (e) {
is WorkingMemoryEntry.User -> LiteMessage(
LiteRole.USER,
applyContextPrefix(e.content.toLiteContents(), e.context),
@@ -217,18 +246,7 @@ internal class CompactionCoordinator(
)
else -> null
}
msg
}
val capped = if (pastTurns.size > MAX_SEEDED_MESSAGES) pastTurns.takeLast(MAX_SEEDED_MESSAGES) else pastTurns
val config = LiteConversationConfig(
systemInstruction = systemPrompt.takeIf { it.isNotBlank() },
initialMessages = capped,
tools = state.tools.map { it.tool },
)
return liteLlm.createConversation(config).also { state.liteConvRef.set(it) }
}
companion object {
@@ -12,9 +12,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import mu.KotlinLogging
@@ -47,9 +45,8 @@ import pw.binom.litert.LiteToolCall
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Instant
import pw.binom.agentik.llm.tools.LlmReflector
import pw.binom.agentik.llm.tools.SkillMiner
import pw.binom.agentik.skill.mining.SkillMiner
import pw.binom.agentik.llm.tools.ContextCompactor
import pw.binom.agentik.toolsets.NamedTool
class ConversationLoop(
record: ConversationRecord,
@@ -59,8 +56,30 @@ class ConversationLoop(
private val reflectionStore: ReflectionStore?,
private val eventStore: pw.binom.agentik.outbox.MutableOutboxStore,
private val llm: LiteLlm,
/**
* Базовый системный промпт, который задаётся беседе при создании.
* В runtime может перетираться через [systemPromptResolver] перед каждым
* `sendStreamContents` (LiteRT v12 mutable setters сами делают no-op,
* если значение эквивалентно).
*/
private val systemPrompt: String,
private val tools: List<NamedTool> = emptyList(),
/**
* Лямбда, которая возвращает актуальный системный промпт в момент
* каждого `sendStreamContents`. По дефолту — возвращает [systemPrompt]
* без изменений. Production-конфигурация (ChatAgent) подменяет её на
* `{ agent.buildRuntimeSystemPrompt() }`, что добавляет к базе динамические
* секции от установленных компонент (`systemProviders`).
*/
internal var systemPromptResolver: () -> String = { systemPrompt },
private val tools: List<LiteTool> = emptyList(),
/**
* Лямбда, которая возвращает актуальный список тулов в момент каждого
* `sendStreamContents`. По дефолту — фиксированный [tools] снимок.
* Production-конфигурация (ChatAgent) подменяет её на
* `{ collectTools() }` — добавляет/убирает тулы от `toolProviders`
* компонент на лету.
*/
internal var toolsResolver: () -> List<LiteTool> = { tools },
private val toolsetDispatch: ToolsetDispatchPolicy? = null,
private val memoryPrefetcher: MemoryPrefetcher? = null,
private val memoryReviewer: MemoryReviewer? = null,
@@ -93,7 +112,7 @@ class ConversationLoop(
/** Per-conversation background event bus. Lifecycle scoped к этому ConversationLoop. */
private val backgroundEvents = BackgroundEventBus()
private val toolsByName: MutableMap<String, NamedTool> = tools.associateBy { it.name }.toMutableMap()
private val toolsByName: MutableMap<String, LiteTool> = tools.associateBy { it.name }.toMutableMap()
private val contextBuilder = ContextBuilder(memoryPrefetcher = memoryPrefetcher)
@@ -118,7 +137,6 @@ class ConversationLoop(
toolsByName = toolsByName,
toolsetDispatch = toolsetDispatch,
newId = ::newId,
encodeArgsJson = ::encodeArgsJson,
now = ::now,
)
@@ -237,10 +255,6 @@ class ConversationLoop(
suspend fun forceCompactNow(): Boolean = compactor.forceCompactNow()
internal fun registerToolForTest(name: String, tool: LiteTool) {
toolDispatcher.registerToolForTest(name, tool)
}
private suspend fun runTurn(userRecord: MessageRecord.UserMessage, turnStarted: Instant) {
val wasInterruptedAtEntry = interrupted.get()
if (!state.isTemporal) {
@@ -275,7 +289,7 @@ class ConversationLoop(
val conv = try {
compactor.getOrCreateLiteConversation(
systemPrompt = systemPrompt,
systemPrompt = systemPromptResolver(),
excludeUserSourceId = if (state.isTemporal) null else userRecord.id,
)
} catch (e: Throwable) {
@@ -307,6 +321,10 @@ class ConversationLoop(
val collectedCalls = mutableListOf<LiteToolCall>()
if (pendingParts != null) {
val lc = state.liteConvRef.get() ?: return
// Hot-swap перед каждым sendStreamContents — v12 setter no-op
// если значение эквивалентно текущему (см. LiteConversation.systemInstruction).
lc.systemInstruction = systemPromptResolver()
lc.tools = toolsResolver()
try {
lc.sendStreamContents(pendingParts).collect { delta ->
if (delta.text.isNotEmpty()) {
@@ -359,6 +377,10 @@ class ConversationLoop(
try {
val collectedPostTool = mutableListOf<LiteToolCall>()
val lc = state.liteConvRef.get() ?: return
// Hot-swap перед follow-up — на случай если компонент
// изменил toolset/soul/skills между turn'ами.
lc.systemInstruction = systemPromptResolver()
lc.tools = toolsResolver()
lc.sendStreamContents(listOf(LiteContentPart.Text(" "))).collect { followUp ->
if (followUp.text.isNotEmpty()) {
reply.append(followUp.text)
@@ -492,28 +514,6 @@ class ConversationLoop(
private fun newId(prefix: String): String = pw.binom.agentik.journal.Ids.new(prefix)
private fun encodeArgsJson(arguments: Map<String, Any?>): String {
val el = JsonElement.serializer()
val obj = buildJsonObject {
arguments.forEach { (k, v) -> put(k, v.toJsonElement()) }
}
return Json.encodeToString(el, obj)
}
private fun Any?.toJsonElement(): JsonElement = when (this) {
null -> JsonNull
is Boolean -> JsonPrimitive(this)
is Number -> JsonPrimitive(this)
is String -> JsonPrimitive(this)
is Map<*, *> -> buildJsonObject {
this@toJsonElement.forEach { (k, v) ->
put(k.toString(), v.toJsonElement())
}
}
is List<*> -> JsonArray(this.map { it.toJsonElement() })
else -> JsonPrimitive(toString())
}
companion object {
private const val MAX_TOOL_LOOPS = 16
}
@@ -3,12 +3,12 @@ package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.CoroutineScope
import pw.binom.agentik.journal.ConversationRecord
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteTool
import java.util.concurrent.atomic.AtomicReference
import pw.binom.agentik.toolsets.NamedTool
internal class ConversationState(
initialRecord: ConversationRecord,
val tools: List<NamedTool>,
val tools: List<LiteTool>,
val agentScope: CoroutineScope,
) {
@Volatile
@@ -1,76 +0,0 @@
package pw.binom.agentik.standalone.agent
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import pw.binom.agentik.skills.SkillCatalog
import pw.binom.litert.LiteTool
/**
* Встроенная тула `read_skill`: отдаёт полный текст скила по имени.
*
* В системный промпт попадают только имя и краткое описание скилов
* (см. `SkillCatalog.renderSystemPromptSection()`); этот тул загружает `body`
* по требованию модели. Если имя неизвестно — возвращаем список доступных
* имён, чтобы модель могла исправиться со следующей попытки.
*/
class SkillReadTool(
private val catalog: SkillCatalog,
) : LiteTool {
override fun describe(): String = buildJsonObject {
// Flat OpenAPI-спецификация (name/description/parameters) — формат, который
// принимает LiteRT-LM (litert-google). litert-openai сам оборачивает её в
// OpenAI-формат {"type":"function","function":{...}}.
put("name", NAME)
put(
"description",
"Load the full text of a skill by its name. " +
"Use it when the task matches one of the skills listed in the system prompt. " +
"Always read a skill before following its instructions.",
)
put("parameters", buildJsonObject {
put("type", "object")
put("properties", buildJsonObject {
put("name", buildJsonObject {
put("type", "string")
put("description", "Skill name exactly as listed in the system prompt.")
})
})
put("required", kotlinx.serialization.json.JsonArray(listOf(JsonPrimitive("name"))))
})
}.toString()
override fun invoke(arguments: String): String {
val name = parseName(arguments)
?: return "[tool error] read_skill: missing required argument \"name\""
val skill = catalog.find(name)
?: return "[tool error] unknown skill \"$name\". Available skills: " +
catalog.skills.joinToString(", ") { it.name }.ifEmpty { "<none>" }
return if (skill.body.isBlank()) {
"(skill \"$name\" has an empty body)"
} else {
skill.body
}
}
private fun parseName(arguments: String): String? {
val raw = arguments.trim()
if (raw.isEmpty()) return null
val obj: JsonObject = runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull() ?: return null
return (obj["name"] as? JsonPrimitive)?.jsonPrimitive?.content?.trim()?.takeIf { it.isNotEmpty() }
}
companion object {
/** Имя тула, как его видит модель. */
const val NAME: String = "read_skill"
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
}
}
@@ -1,168 +0,0 @@
package pw.binom.agentik.standalone.agent
import pw.binom.agentik.skills.SkillFile
import pw.binom.agentik.skills.SkillStore
import pw.binom.litert.LiteTool
/**
* Тул `skill_save(name, description, body)` — сохраняет скил через [SkillStore].
*
* Схема аргументов (function-calling JSON):
* ```
* {
* "name": "skill_save",
* "description": "Создать или обновить скил. Имя может содержать двоеточия (как в opencode: 'backend:spring:db-base').",
* "parameters": {
* "type": "object",
* "properties": {
* "name": {"type": "string", "description": "Имя скила, уникальное в каталоге."},
* "description": {"type": "string", "description": "Одно-два предложения: когда применять."},
* "body": {"type": "string", "description": "Markdown body скила."}
* },
* "required": ["name", "description", "body"]
* }
* }
* ```
*
* ВАЖНО: параметр LiteTool-лямбды не называется `invoke` — иначе Kotlin
* резолвит `invoke(x)` рекурсивно и StackOverflow (см. MemoryToolsFactory).
*/
internal class SkillSaveTool(
private val store: SkillStore,
) : LiteTool {
override fun describe(): String = SCHEMA
override fun invoke(arguments: String): String {
val parsed = parseArgs(arguments)
?: return error("invalid arguments: $arguments")
val (name, description, body) = parsed
return try {
store.upsert(SkillFile(name = name, description = description, body = body))
"""{"ok":true,"name":"${escape(name)}"}"""
} catch (e: Exception) {
error(e.message ?: "upsert failed")
}
}
private fun parseArgs(arguments: String): Triple<String, String, String>? {
// Минимальный парсер JSON-объекта — вытаскиваем три строковых поля.
// Полагаемся на порядок ключей в агенте: name, description, body.
val name = extractString(arguments, "name") ?: return null
val description = extractString(arguments, "description") ?: return null
val body = extractString(arguments, "body") ?: return null
return Triple(name, description, body)
}
companion object {
private const val SCHEMA = """
{
"name": "skill_save",
"description": "Создать или обновить скил. Имя может содержать двоеточия (например 'backend:spring:db-base'). Скил станет доступен в этом и следующих сеансах через read_skill.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"body": {"type": "string"}
},
"required": ["name", "description", "body"]
}
}
"""
}
}
/**
* Тул `skill_delete(name)` — архивирует скил.
*/
internal class SkillDeleteTool(
private val store: SkillStore,
) : LiteTool {
override fun describe(): String = SCHEMA
override fun invoke(arguments: String): String {
val name = extractString(arguments, "name")
?: return error("missing 'name' in arguments: $arguments")
return try {
val removed = store.remove(name)
if (removed) {
"""{"ok":true,"archived":"${escape(name)}"}"""
} else {
error("skill '$name' not found")
}
} catch (e: Exception) {
error(e.message ?: "delete failed")
}
}
companion object {
private const val SCHEMA = """
{
"name": "skill_delete",
"description": "Архивировать скил по имени. Скил больше не будет появляться в read_skill, но файл остаётся на диске с суффиксом .archived.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"}
},
"required": ["name"]
}
}
"""
}
}
/**
* Достаёт строковое значение из JSON-объекта по ключу. Минимальный парсер —
* JSON простой (плоский объект с известным набором ключей), как в
* MemoryToolsFactory.
*/
internal fun extractString(json: String, key: String): String? {
val keyIdx = json.indexOf("\"$key\"")
if (keyIdx < 0) return null
val colon = json.indexOf(':', keyIdx)
if (colon < 0) return null
val firstQuote = json.indexOf('"', colon)
if (firstQuote < 0) return null
var i = firstQuote + 1
val sb = StringBuilder()
while (i < json.length) {
val c = json[i]
when {
c == '\\' && i + 1 < json.length -> {
when (val next = json[i + 1]) {
'n' -> sb.append('\n')
't' -> sb.append('\t')
'r' -> sb.append('\r')
'"' -> sb.append('"')
'\\' -> sb.append('\\')
else -> sb.append(next)
}
i += 2
}
c == '"' -> return sb.toString()
else -> {
sb.append(c)
i++
}
}
}
return null
}
/** JSON-escape строки. */
internal fun escape(s: String): String = buildString(s.length + 2) {
for (c in s) {
when (c) {
'"' -> append("\\\"")
'\\' -> append("\\\\")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(c)
}
}
}
/** Строит `{"error":"..."}` JSON-ответ. */
internal fun error(message: String): Nothing = throw IllegalStateException(message)
@@ -1,22 +0,0 @@
package pw.binom.agentik.standalone.agent
import pw.binom.agentik.skills.SkillStore
import pw.binom.agentik.toolsets.NamedTool
/**
* Фабрика tools для self-improvement'а скилов (Phase 3 Hermes-style).
*
* Возвращает два NamedTool'а:
* - `skill_save(name, description, body)` — создать или обновить скил;
* - `skill_delete(name)` — архивировать скил.
*
* Оба оборачивают [SkillStore]; агент может дёргать их в любой момент,
* не только во время review-loop.
*/
object SkillToolsFactory {
fun create(store: SkillStore): List<NamedTool> = listOf(
NamedTool("skill_save", SkillSaveTool(store)),
NamedTool("skill_delete", SkillDeleteTool(store)),
)
}
@@ -12,17 +12,15 @@ import pw.binom.agentik.toolsets.ToolsetDispatchPolicy
import pw.binom.litert.LiteToolCall
import pw.binom.litert.LiteTool
import kotlin.time.Instant
import pw.binom.agentik.toolsets.NamedTool
internal class ToolDispatcher(
private val state: ConversationState,
private val messageStore: MutableJournalStore,
private val events: ConversationEvents,
private val backgroundEvents: BackgroundEventBus,
private val toolsByName: MutableMap<String, NamedTool>,
private val toolsByName: MutableMap<String, LiteTool>,
private val toolsetDispatch: ToolsetDispatchPolicy?,
private val newId: (String) -> String,
private val encodeArgsJson: (Map<String, Any?>) -> String,
private val now: () -> Instant,
) {
private val log = KotlinLogging.logger {}
@@ -32,14 +30,11 @@ internal class ToolDispatcher(
val currentToolJob: Job? get() = _currentToolJob
internal fun registerToolForTest(name: String, tool: LiteTool) {
toolsByName[name] = NamedTool(name = name, tool = tool)
}
suspend fun runToolAndPersist(call: LiteToolCall): WorkingMemoryEntry.ToolExchange {
val callId = newId("tc")
val resultId = newId("tr")
val argsJson = encodeArgsJson(call.arguments)
// v10: LiteToolCall.arguments — уже raw JSON-строка (раньше была Map<String, Any?>).
val argsJson = call.arguments
val nowTs = now()
val startMs = System.currentTimeMillis()
@@ -65,7 +60,7 @@ internal class ToolDispatcher(
log.warn { "tool '${call.name}' requested but not registered" }
"[tool not found: ${call.name}]"
} else {
t.tool.invoke(argsJson)
t.invoke(argsJson)
}
} else {
val d = toolsetDispatch
@@ -0,0 +1,52 @@
package pw.binom.agentik.standalone.agent.memory
import pw.binom.agentik.agent.Component
import pw.binom.agentik.agent.MutableAgent
import pw.binom.agentik.agent.SystemPromptProvider
import pw.binom.agentik.agent.ToolProvider
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemorySystemGuidance
import pw.binom.litert.LiteTool
/**
* Подключает память агенту:
* - Тулы memory_save / memory_read / memory_list / memory_delete —
* через [MemoryToolProvider].
* - Секция системного промпта с категориями и форматом записи —
* через [MemorySystemProvider].
*
* Память опциональна — если [MemoryStore] == null, компонент не регистрирует
* ни провайдеры, ни system-секцию. Поведение идентично прошлому (когда
* `memoryStore=null` ничего не добавлялось).
*/
class MemoryComponent(
private val store: MemoryStore?,
) : Component {
private var toolProvider: MemoryToolProvider? = null
override fun install(agent: MutableAgent) {
if (store == null) return
val tp = MemoryToolProvider(store)
agent.toolProviders.add(tp)
toolProvider = tp
agent.systemProviders.add(MemorySystemProvider())
}
override fun uninstall(agent: MutableAgent) {
toolProvider?.let { agent.toolProviders.remove(it) }
agent.systemProviders.removeAll { it is MemorySystemProvider }
}
}
private class MemoryToolProvider(
private val store: MemoryStore,
) : ToolProvider {
override fun getTools(conversationId: String): List<LiteTool> =
MemoryToolsFactory.create(store)
}
private class MemorySystemProvider : SystemPromptProvider {
override fun getSection(conversationId: String): String =
MemorySystemGuidance.MEMORY_GUIDANCE
}
@@ -1,16 +1,6 @@
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 kotlinx.serialization.Serializable
import pw.binom.agentik.memory.DefaultMemoryTools
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
@@ -19,13 +9,19 @@ 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 pw.binom.agentik.toolsets.liteToolSuspend
import pw.binom.litert.LiteTool
import java.util.UUID
import kotlin.time.Clock
import kotlinx.serialization.json.jsonObject
import pw.binom.agentik.toolsets.NamedTool
private val MemoryToolsJson = Json { ignoreUnknownKeys = true; isLenient = true }
private val MemoryResponseJson = Json { encodeDefaults = true }
private val MemoryResponseJson = kotlinx.serialization.json.Json { encodeDefaults = true }
private val ToolInvocationJson = kotlinx.serialization.json.Json {
ignoreUnknownKeys = true
isLenient = false
coerceInputValues = true
explicitNulls = false
}
/**
* Превращает [NewMemoryNote] (из reviewer'а) в полноценную [MemoryNote],
@@ -48,169 +44,89 @@ fun materializeReviewNote(n: NewMemoryNote, conversationId: String? = null): Mem
/** Сериализация результатов поиска для 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)))
kotlinx.serialization.json.buildJsonObject {
put("id", kotlinx.serialization.json.JsonPrimitive(r.note.id))
put("category", kotlinx.serialization.json.JsonPrimitive(r.note.category.id))
put("score", kotlinx.serialization.json.JsonPrimitive(r.score))
put("content", kotlinx.serialization.json.JsonPrimitive(truncate(r.note.content, 300)))
}
}
return MemoryResponseJson.encodeToString(JsonElement.serializer(), buildJsonArray { items.forEach { add(it) } })
return MemoryResponseJson.encodeToString(
kotlinx.serialization.json.JsonElement.serializer(),
kotlinx.serialization.json.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()))
kotlinx.serialization.json.buildJsonObject {
put("id", kotlinx.serialization.json.JsonPrimitive(n.id))
put("category", kotlinx.serialization.json.JsonPrimitive(n.category.id))
put("content", kotlinx.serialization.json.JsonPrimitive(truncate(n.content, 300)))
put("created_at", kotlinx.serialization.json.JsonPrimitive(n.createdAt.toString()))
}
}
return MemoryResponseJson.encodeToString(JsonElement.serializer(), buildJsonArray { items.forEach { add(it) } })
return MemoryResponseJson.encodeToString(
kotlinx.serialization.json.JsonElement.serializer(),
kotlinx.serialization.json.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()) }
@Serializable
internal data class SaveArgs(val category: String, val content: String)
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('"')
}
@Serializable
internal data class ReadArgs(val query: String, val top_k: Int = 5, val category: String? = null)
private fun objInt(s: String, key: String): Int? = objString(s, key)?.toIntOrNull()
@Serializable
internal data class ListArgs(val category: String? = null, val limit: Int = 20)
/**
* Flat OpenAPI-спецификация тула (name/description/parameters) — формат,
* который напрямую принимает LiteRT-LM (litert-google). litert-openai
* оборачивает её в OpenAI-формат сам. [name] совпадает с именем NamedTool.
*/
private fun toolDescribe(name: String, description: String, parameters: kotlinx.serialization.json.JsonObject): String =
buildJsonObject {
put("name", name)
put("description", description)
put("parameters", parameters)
}.toString()
/** parameters-блок JSON-Schema: {"type":"object","properties":{...},"required":[...]}. */
private fun objectSchema(
properties: kotlinx.serialization.json.JsonObject,
required: List<String> = emptyList(),
): kotlinx.serialization.json.JsonObject = buildJsonObject {
put("type", "object")
put("properties", properties)
if (required.isNotEmpty()) put("required", buildJsonArray { required.forEach { add(it) } })
}
@Serializable
internal data class DeleteArgs(val id: String)
/** memory_save(category, content) → upsert. */
internal fun saveTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = toolDescribe(
"memory_save",
DefaultMemoryTools.save.description,
objectSchema(
properties = buildJsonObject {
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")
}
},
required = listOf("category", "content"),
),
),
internal fun saveTool(store: MemoryStore): LiteTool = liteToolSuspend<SaveArgs>(
name = "memory_save",
description = DefaultMemoryTools.save.description,
) { 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))
val category = runCatching { MemoryCategory.fromId(args.category) }.getOrNull()
?: return@liteToolSuspend """{"error":"category required"}"""
if (args.content.isBlank()) return@liteToolSuspend """{"error":"content is blank"}"""
val note = materializeReviewNote(NewMemoryNote(category, args.content))
store.upsert(note)
"""{"ok":true,"id":"${note.id}"}"""
}
/** memory_read(query, top_k?, category?) → search. */
internal fun readTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = toolDescribe(
"memory_read",
DefaultMemoryTools.read.description,
objectSchema(
properties = buildJsonObject {
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") })
}
},
required = listOf("query"),
),
),
internal fun readTool(store: MemoryStore): LiteTool = liteToolSuspend<ReadArgs>(
name = "memory_read",
description = DefaultMemoryTools.read.description,
) { 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() }
val category = args.category?.takeIf { it.isNotBlank() }
?.let { runCatching { MemoryCategory.fromId(it) }.getOrNull() }
val results = store.search(MemorySearchQuery(query = query, topK = topK, category = category))
val results = store.search(MemorySearchQuery(query = args.query, topK = args.top_k, category = category))
serializeSearchResults(results)
}
/** memory_list(category?, limit?) → list. */
internal fun listTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = toolDescribe(
"memory_list",
DefaultMemoryTools.list.description,
objectSchema(
properties = buildJsonObject {
putJsonObject("category") {
put("type", "string")
put("enum", buildJsonArray { add("user"); add("world"); add("preference") })
}
putJsonObject("limit") {
put("type", "integer")
put("description", "default 20")
}
},
),
),
internal fun listTool(store: MemoryStore): LiteTool = liteToolSuspend<ListArgs>(
name = "memory_list",
description = DefaultMemoryTools.list.description,
) { args ->
val category = objString(args, "category")?.takeIf { it.isNotBlank() }
val category = 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)
val notes = store.list(category = category, limit = args.limit)
serializeNotes(notes)
}
/** memory_delete(id) → delete. */
internal fun deleteTool(store: MemoryStore): SyncLiteTool = SyncLiteTool(
describeJson = toolDescribe(
"memory_delete",
DefaultMemoryTools.delete.description,
objectSchema(
properties = buildJsonObject {
putJsonObject("id") {
put("type", "string")
put("description", "memory note id (mem-...)")
}
},
required = listOf("id"),
),
),
internal fun deleteTool(store: MemoryStore): LiteTool = liteToolSuspend<DeleteArgs>(
name = "memory_delete",
description = DefaultMemoryTools.delete.description,
) { args ->
val id = objString(args, "id") ?: return@SyncLiteTool """{"error":"id required"}"""
if (store.delete(id)) """{"ok":true,"deleted":"$id"}""" else """{"ok":false,"missing":"$id"}"""
if (store.delete(args.id)) """{"ok":true,"deleted":"${args.id}"}""" else """{"ok":false,"missing":"${args.id}"}"""
}
@@ -1,34 +1,23 @@
package pw.binom.agentik.standalone.agent.memory
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.memory.MemoryStore
import pw.binom.litert.LiteTool
import pw.binom.agentik.toolsets.NamedTool
/**
* Обёртки `DefaultMemoryTools` (memory_save / memory_read / memory_list / memory_delete)
* поверх конкретного [MemoryStore]. Каждый инструмент возвращает JSON-строку,
* совместимую с тем, что отдают остальные NamedTool'ы в проекте.
* совместимую с тем, что отдают остальные LiteTool'ы в проекте.
*
* Реализация каждого тула — типизированный `@Serializable Args` + suspend handler —
* через `pw.binom.agentik.toolsets.liteToolSuspend` (сам построен на
* `pw.binom.litert.tools.kotlinx.serialization.liteTool`).
*/
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)),
fun create(store: MemoryStore): List<LiteTool> = listOf(
saveTool(store),
readTool(store),
listTool(store),
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) }
}
@@ -0,0 +1,36 @@
package pw.binom.agentik.standalone.agent.soul
import pw.binom.agentik.agent.Component
import pw.binom.agentik.agent.MutableAgent
import pw.binom.agentik.agent.SystemPromptProvider
/**
* Подключает "душу" — фиксированную секцию системного промпта,
* идущую первой (до базы, навыков, памяти).
*
* Используется для статичной персональной информации (persona, поведение),
* которую LLM должна видеть в каждом turn'е.
*/
class SoulComponent(
private val body: String?,
) : Component {
private var provider: SoulSystemProvider? = null
override fun install(agent: MutableAgent) {
if (body.isNullOrBlank()) return
val sp = SoulSystemProvider(body)
agent.systemProviders.add(sp)
provider = sp
}
override fun uninstall(agent: MutableAgent) {
provider?.let { agent.systemProviders.remove(it) }
}
}
private class SoulSystemProvider(
private val body: String,
) : SystemPromptProvider {
override fun getSection(conversationId: String): String = body.trim()
}
@@ -0,0 +1,59 @@
package pw.binom.agentik.standalone.persistence
import pw.binom.agentik.context.ContextStore
import pw.binom.agentik.context.ksqlite.KsqliteContextStore
import pw.binom.agentik.journal.MutableJournalStore
import pw.binom.agentik.journal.MutableConversationStore
import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore
import pw.binom.agentik.journal.ksqlite.KsqliteMutableConversationStore
import pw.binom.agentik.reflection.ReflectionStore
import pw.binom.agentik.reflection.ksqlite.KsqliteReflectionStore
import pw.binom.db.ksqlite.SQLiteConnection
/**
* Bundle из 4 ksqlite-сторов для standalone-агента.
*
* Internal helper `:standalone` — bundle нужен только агенту, поэтому не
* торчит наружу через публичный API модуля. Каждый store (conversation,
* message, working_memory, reflection) живёт в своём ksqlite-модуле;
* этот класс собирает их вокруг одной shared-connection и закрывает их
* в правильном порядке в [close].
*
* Lifecycle: открывает [SQLiteConnection] и возвращает 4 store'а. Каждый
* store сам прогоняет свою схему в конструкторе (`Schema.migrate(connection)`
* — idempotent `CREATE TABLE IF NOT EXISTS`), явных вызовов миграции в bundle
* нет. Caller ДОЛЖЕН вызвать [close] при завершении.
*
* Закрытие в правильном порядке: зависимые сторы → владелец connection.
*
* @param path путь к .db файлу, либо URI для in-memory/shared-cache.
*/
internal class SqliteStores internal constructor(
val connection: SQLiteConnection,
val conversations: MutableConversationStore,
val messages: MutableJournalStore,
val workingMemory: ContextStore,
val reflections: ReflectionStore,
) : AutoCloseable {
override fun close() {
conversations.close()
messages.close()
workingMemory.close()
reflections.close()
connection.close()
}
companion object {
fun open(path: String): SqliteStores = assemble(SQLiteConnection.open(path))
fun inMemory(name: String = "agentik-test"): SqliteStores = assemble(SQLiteConnection.memory(name))
private fun assemble(conn: SQLiteConnection): SqliteStores = SqliteStores(
connection = conn,
conversations = KsqliteMutableConversationStore(conn),
messages = KsqliteJournalStore(conn),
workingMemory = KsqliteContextStore(conn),
reflections = KsqliteReflectionStore(conn),
)
}
}
@@ -4,8 +4,8 @@ import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Instant
import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.reflection.Reflection
import pw.binom.agentik.memory.MemorySystemGuidance
class ChatAgentReflectionTest {
@@ -13,9 +13,7 @@ class ChatAgentReflectionTest {
fun `buildSystemPrompt omits section when reflections empty`() {
val prompt = buildSystemPrompt(
base = "base",
skills = SkillCatalog.EMPTY,
memoryEnabled = false,
soulBody = null,
reflections = emptyList(),
)
assertFalse(prompt.contains("Self-reflection"))
@@ -35,9 +33,7 @@ class ChatAgentReflectionTest {
)
val prompt = buildSystemPrompt(
base = "base",
skills = SkillCatalog.EMPTY,
memoryEnabled = false,
soulBody = null,
reflections = listOf(r),
)
assertTrue(prompt.contains("Self-reflection"))
@@ -48,7 +44,10 @@ class ChatAgentReflectionTest {
}
@Test
fun `buildSystemPrompt places reflection section after memory and before soul prepend`() {
fun `buildSystemPrompt places reflection section after memory section`() {
// Memory теперь приходит от MemoryComponent → SystemPromptProvider; порядок
// провайдеров определяется порядком install(). Тест проверяет базовый
// порядок внутри buildSystemPrompt — reflection идёт после памяти.
val r = Reflection(
id = "r1",
conversationId = null,
@@ -60,15 +59,15 @@ class ChatAgentReflectionTest {
)
val prompt = buildSystemPrompt(
base = "base",
skills = SkillCatalog.EMPTY,
memoryEnabled = true,
soulBody = "I am a soul",
reflections = listOf(r),
)
// soul первый, reflection последняя
val soulIdx = prompt.indexOf("I am a soul")
assertTrue(prompt.contains("base"), "base missing: $prompt")
assertTrue(prompt.contains(MemorySystemGuidance.MEMORY_GUIDANCE.take(20)), "memory section missing: $prompt")
assertTrue(prompt.contains("Self-reflection"), "reflection section missing: $prompt")
val baseIdx = prompt.indexOf("base")
val memIdx = prompt.indexOf(MemorySystemGuidance.MEMORY_GUIDANCE.take(20))
val reflIdx = prompt.indexOf("Self-reflection")
assertTrue(soulIdx >= 0 && reflIdx >= 0)
assertTrue(soulIdx < reflIdx, "soul должен идти перед reflection")
assertTrue(baseIdx < memIdx && memIdx < reflIdx, "expected base < memory < reflection, got $baseIdx/$memIdx/$reflIdx")
}
}
@@ -10,13 +10,14 @@ import kotlinx.coroutines.test.runTest
import pw.binom.agentik.outbox.AgentEvent
import pw.binom.agentik.proto.Content
import pw.binom.agentik.outbox.Event as ProtoEvent
import pw.binom.agentik.skill.mining.SkillReadTool
import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillFile
import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.journal.MessageRecord
import pw.binom.agentik.context.WorkingMemoryEntry
import pw.binom.agentik.storage.ksqlite.KsqliteStores
import pw.binom.agentik.standalone.persistence.SqliteStores
import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
@@ -37,16 +38,15 @@ import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import pw.binom.agentik.toolsets.NamedTool
class ChatAgentTest {
private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores
private lateinit var sqliteStores: pw.binom.agentik.standalone.persistence.SqliteStores
private lateinit var fakeLlm: FakeLiteLlm
@BeforeTest
fun setup() {
sqliteStores = KsqliteStores.inMemory("chat-${kotlin.random.Random.nextLong()}")
sqliteStores = SqliteStores.inMemory("chat-${kotlin.random.Random.nextLong()}")
fakeLlm = FakeLiteLlm()
}
@@ -56,25 +56,28 @@ class ChatAgentTest {
}
private fun newAgent(
sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores = this.sqliteStores,
sqliteStores: pw.binom.agentik.standalone.persistence.SqliteStores = this.sqliteStores,
llm: LiteLlm = this.fakeLlm,
tools: List<NamedTool> = emptyList(),
skills: SkillCatalog = SkillCatalog.EMPTY,
): ChatAgent = ChatAgent(
id = "agentik",
mutableConversationStore = sqliteStores.conversations,
messageStore = sqliteStores.messages,
workingMemoryStore = sqliteStores.workingMemory,
reflectionStore = sqliteStores.reflections,
llm = llm,
llmConfig = LlmConfig(
backend = LlmBackend.OPENAI,
systemPrompt = "be brief",
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
),
tools = tools,
skills = skills,
)
tools: List<LiteTool> = emptyList(),
skillStore: pw.binom.agentik.skills.SkillStore? = null,
): ChatAgent {
val agent = ChatAgent(
id = "agentik",
mutableConversationStore = sqliteStores.conversations,
messageStore = sqliteStores.messages,
workingMemoryStore = sqliteStores.workingMemory,
reflectionStore = sqliteStores.reflections,
skillStore = skillStore,
llm = llm,
llmConfig = LlmConfig(
backend = LlmBackend.OPENAI,
systemPrompt = "be brief",
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
),
)
tools.forEach { agent.addTestTool(it) }
return agent
}
@Test
fun `createConversation does NOT seed system prompt into working memory`() = runTest {
@@ -93,14 +96,13 @@ class ChatAgentTest {
@Test
fun `skills are appended to the system prompt passed to LLM`() = runTest {
// Skills добавляются в system prompt на лету при сборке ChatAgent.
// Проверяем это через то, что увидит LLM — systemInstruction в
// LiteConversationConfig (а не через working_memory, куда теперь
// ничего про system prompt не пишется).
val skills = SkillCatalog(
// Скиллы теперь приходят через SkillComponent → SkillSystemProvider →
// buildRuntimeSystemPrompt() при каждом turn'е. Проверяем через
// systemInstruction, который увидит LLM.
val skillStore = InMemorySkillStore(
listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")),
)
val agent = newAgent(skills = skills)
val agent = newAgent(skillStore = skillStore)
val conv = agent.createConversation(temp = false)
fakeLlm.reply = "ok"
conv.send(listOf(Content.Text("hi")))
@@ -116,10 +118,10 @@ class ChatAgentTest {
@Test
fun `read_skill tool is registered when skills present`() = runTest {
val skills = SkillCatalog(
val skillStore = InMemorySkillStore(
listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")),
)
val agent = newAgent(skills = skills)
val agent = newAgent(skillStore = skillStore)
val conv = agent.createConversation(temp = false)
fakeLlm.reply = "ok"
conv.send(listOf(Content.Text("hi")))
@@ -419,14 +421,15 @@ class ChatAgentTest {
// LLM скриптован: tool call.
fakeLlm.scriptedReplies = mutableListOf(
FakeLiteLlm.Reply.ToolCalls(listOf("echo_tool" to mapOf("q" to "hi"))),
FakeLiteLlm.Reply.ToolCalls(listOf("echo_tool" to """{"q":"hi"}""")),
)
val echoTool = object : LiteTool {
override val name: String = "echo_tool"
override fun describe(): String = """{"name":"echo_tool","description":"echoes args"}"""
override fun invoke(arguments: String): String = """{"echo":$arguments}"""
}
agent.registerToolForTest("echo_tool", echoTool)
agent.addTestTool(echoTool)
// Подписываемся ДО send — SharedFlow без replay
val events = mutableListOf<ProtoEvent>()
@@ -472,7 +475,7 @@ class ChatAgentTest {
// Поднимаем file-backed БД, создаём temp-беседу
sqliteStores.close()
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
sqliteStores = KsqliteStores.open(dbPath)
sqliteStores = SqliteStores.open(dbPath)
val agent1 = ChatAgent(
id = "agentik",
mutableConversationStore = sqliteStores.conversations,
@@ -492,7 +495,7 @@ class ChatAgentTest {
// Переоткрываем БД — temp-беседа не должна пережить рестарт
sqliteStores.close()
sqliteStores = KsqliteStores.open(dbPath)
sqliteStores = SqliteStores.open(dbPath)
val agent2 = ChatAgent(
id = "agentik",
mutableConversationStore = sqliteStores.conversations,
@@ -514,7 +517,7 @@ class ChatAgentTest {
fun `non-temp conversation persists across agent instances`() = runTest {
sqliteStores.close()
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
sqliteStores = KsqliteStores.open(dbPath)
sqliteStores = SqliteStores.open(dbPath)
val agent1 = ChatAgent(
id = "agentik",
mutableConversationStore = sqliteStores.conversations,
@@ -532,7 +535,7 @@ class ChatAgentTest {
val id = conv.id
sqliteStores.close()
sqliteStores = KsqliteStores.open(dbPath)
sqliteStores = SqliteStores.open(dbPath)
val agent2 = ChatAgent(
id = "agentik",
mutableConversationStore = sqliteStores.conversations,
@@ -564,11 +567,12 @@ class ChatAgentTest {
@Test
fun `tool-call loop executes registered tool and feeds result back`() = runTest {
val echoTool = object : LiteTool {
override val name: String = "echo"
override fun describe(): String = """{"type":"function","function":{"name":"echo"}}"""
override fun invoke(arguments: String): String = "echoed: $arguments"
}
val toolLlm = ToolLoopFakeLiteLlm()
val agent = newAgent(llm = toolLlm, tools = listOf(NamedTool("echo", echoTool)))
val agent = newAgent(llm = toolLlm, tools = listOf(echoTool))
val conv = agent.createConversation(temp = false)
conv.send(listOf(pw.binom.agentik.proto.Content.Text("call the tool")))
@@ -624,6 +628,12 @@ private class ToolLoopFakeLiteLlm : LiteLlm {
return object : LiteConversation {
private val hist = mutableListOf<LiteMessage>()
override val history: List<LiteMessage> get() = hist.toList()
override var systemInstruction: String? = config.systemInstruction
override var tools: List<pw.binom.litert.LiteTool> = config.tools
override fun replaceHistory(newHistory: List<LiteMessage>) {
hist.clear()
hist.addAll(newHistory)
}
override fun sendStream(prompt: String) = sendStreamContents(listOf(LiteContentPart.Text(prompt)))
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
hist.add(LiteMessage(LiteRole.USER, contents))
@@ -632,7 +642,7 @@ private class ToolLoopFakeLiteLlm : LiteLlm {
LiteDelta(
text = "",
isDone = true,
toolCalls = listOf(LiteToolCall(name = "echo", arguments = mapOf("x" to "hi"))),
toolCalls = listOf(LiteToolCall(name = "echo", arguments = """{"x":"hi"}""")),
),
)
}
@@ -2,7 +2,7 @@ package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.storage.ksqlite.KsqliteStores
import pw.binom.agentik.standalone.persistence.SqliteStores
import pw.binom.agentik.toolsets.ToolsetContribution
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteTool
@@ -20,6 +20,7 @@ import kotlin.test.assertTrue
class ChatAgentToolsetsTest {
private fun stubTool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
override val name: String = name
override fun describe() = """{"name":"$name","description":"stub","parameters":{"type":"object","properties":{}}}"""
override fun invoke(arguments: String) = response
}
@@ -39,8 +40,8 @@ class ChatAgentToolsetsTest {
private fun newAgent(
toolsets: List<ToolsetContribution> = emptyList(),
): Pair<ChatAgent, pw.binom.agentik.storage.ksqlite.KsqliteStores> {
val sqliteStores = KsqliteStores.inMemory("toolsets-${kotlin.random.Random.nextLong()}")
): Pair<ChatAgent, pw.binom.agentik.standalone.persistence.SqliteStores> {
val sqliteStores = SqliteStores.inMemory("toolsets-${kotlin.random.Random.nextLong()}")
val agent = ChatAgent(
id = "test-agent",
mutableConversationStore = sqliteStores.conversations,
@@ -183,9 +184,18 @@ class ChatAgentToolsetsTest {
// Вспомогательные extension'ы — открываем systemPrompt/dispatch наружу для тестов.
internal fun ChatConversation.systemPromptForTest(): String {
// Через рефлексию достаём private val systemPrompt.
val f = this::class.java.getDeclaredField("systemPrompt").apply { isAccessible = true }
return f.get(this) as String
// v12+: секция "## Toolsets" приходит от ToolsetSystemProvider'а в
// buildRuntimeSystemPrompt() (динамически). Получаем промт агента
// через тот же путь, что и production turn'ы.
val agentField = ChatAgent::class.java.declaredFields.first { it.name == "components" }.apply { isAccessible = true }
val agent = agentField.let {
// Доступ через компоненты неудобен — лучше через саму agent-instance.
// ChatConversation хранит systemPromptResolver, который замыкается над ChatAgent.
val resolverField = this::class.java.declaredFields.first { it.name == "systemPromptResolver" }.apply { isAccessible = true }
@Suppress("UNCHECKED_CAST")
(resolverField.get(this) as () -> String).invoke()
}
return agent
}
internal suspend fun ChatConversation.dispatchToolForTest(toolName: String, argsJson: String): String {
@@ -11,7 +11,7 @@ import pw.binom.agentik.memory.md.KeywordMdReviewer
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.storage.ksqlite.KsqliteStores
import pw.binom.agentik.standalone.persistence.SqliteStores
import pw.binom.agentik.proto.Content as ProtoContent
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
@@ -40,12 +40,12 @@ import pw.binom.agentik.llm.tools.SummaryTurn
*/
class CompactionTest {
private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores
private lateinit var sqliteStores: pw.binom.agentik.standalone.persistence.SqliteStores
private lateinit var fakeLlm: FakeLiteLlm
@BeforeTest
fun setup() {
sqliteStores = KsqliteStores.inMemory("compact-${kotlin.random.Random.nextLong()}")
sqliteStores = SqliteStores.inMemory("compact-${kotlin.random.Random.nextLong()}")
fakeLlm = FakeLiteLlm()
}
@@ -18,7 +18,7 @@ import pw.binom.litert.LiteToolCall
internal class FakeLiteLlm : LiteLlm {
sealed class Reply {
data class Text(val text: String) : Reply()
data class ToolCalls(val calls: List<Pair<String, Map<String, Any?>>>) : Reply()
data class ToolCalls(val calls: List<Pair<String, String>>) : Reply()
}
override val backendName: String = "fake"
@@ -67,6 +67,8 @@ internal class FakeLiteConversation(
val initialMessages: List<LiteMessage> = config.initialMessages
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
override val history: List<LiteMessage> get() = mutableHistory.toList()
override var systemInstruction: String? = config.systemInstruction
override var tools: List<pw.binom.litert.LiteTool> = config.tools
override fun sendStream(prompt: String): Flow<LiteDelta> =
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
@@ -104,6 +106,11 @@ internal class FakeLiteConversation(
}
}
}
override fun replaceHistory(newHistory: List<LiteMessage>) {
mutableHistory.clear()
mutableHistory.addAll(newHistory)
}
override fun send(prompt: String): String {
parent.lastContents = listOf(LiteContentPart.Text(prompt))
return parent.reply
@@ -0,0 +1,28 @@
package pw.binom.agentik.standalone.agent
import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillFile
import pw.binom.agentik.skills.SkillStore
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
/** Минимальный in-memory [SkillStore] для commonTest. */
class InMemorySkillStore(initial: List<SkillFile> = emptyList()) : SkillStore {
private val lock = ReentrantLock()
private val active = LinkedHashMap<String, SkillFile>().apply {
initial.forEach { put(it.name, it) }
}
override val catalog: SkillCatalog
get() = lock.withLock { SkillCatalog(active.values.toList()) }
override fun upsert(skill: SkillFile) = lock.withLock {
active[skill.name] = skill
}
override fun remove(name: String): Boolean = lock.withLock {
active.remove(name) != null
}
override fun close() = Unit
}
@@ -30,7 +30,7 @@ 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.storage.ksqlite.KsqliteStores
import pw.binom.agentik.standalone.persistence.SqliteStores
import pw.binom.agentik.llm.tools.ContextCompactor
import pw.binom.agentik.llm.tools.SummaryTurn
import kotlin.test.AfterTest
@@ -49,13 +49,13 @@ import kotlin.time.Instant
*/
class MemoryWiringTest {
private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores
private lateinit var sqliteStores: pw.binom.agentik.standalone.persistence.SqliteStores
private lateinit var fakeLlm: FakeLiteLlm
private lateinit var root: Path
@BeforeTest
fun setup() {
sqliteStores = KsqliteStores.inMemory("memwire-${kotlin.random.Random.nextLong()}")
sqliteStores = SqliteStores.inMemory("memwire-${kotlin.random.Random.nextLong()}")
fakeLlm = FakeLiteLlm()
root = Path(SystemTemporaryDirectory.toString(), "agentik-mem-${java.util.UUID.randomUUID()}")
SystemFileSystem.createDirectories(root, mustCreate = true)
@@ -173,7 +173,7 @@ class MemoryWiringTest {
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}")
assertTrue(t.describe().contains("\"description\""), "describe() for ${t.name}")
}
system.close()
}
@@ -182,23 +182,23 @@ class MemoryWiringTest {
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(
val saveResult = tools.getValue("memory_save").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}""")
val listResult = tools.getValue("memory_list").invoke("""{"limit":10}""")
assertTrue(listResult.contains("prefers tabs over spaces"),
"list returned: $listResult")
val readResult = tools.getValue("memory_read").tool.invoke(
val readResult = tools.getValue("memory_read").invoke(
"""{"query":"tabs","top_k":3}""",
)
assertTrue(readResult.contains("prefers tabs over spaces"),
"read returned: $readResult")
val deleteResult = tools.getValue("memory_delete").tool.invoke(
val deleteResult = tools.getValue("memory_delete").invoke(
Regex("\"id\":\"(mem-[^\"]+)\"").find(saveResult)?.let { m ->
"""{"id":"${m.groupValues[1]}"}"""
} ?: error("save did not return id"),
@@ -1,71 +0,0 @@
package pw.binom.agentik.standalone.agent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import pw.binom.agentik.llm.tools.ReflectionParser
class ReflectionParserTest {
@Test
fun `parses clean JSON`() {
val raw = """{"score": 4, "summary": "ok", "weakSpots": ["a", "b"]}"""
val p = ReflectionParser.parse(raw)
assertNotNull(p)
assertEquals(4, p.score)
assertEquals("ok", p.summary)
assertEquals(listOf("a", "b"), p.weakSpots)
}
@Test
fun `parses JSON wrapped in json fences`() {
val raw = "```json\n" +
"{\"score\": 3, \"summary\": \"norm\", \"weakSpots\": []}\n" +
"```"
val p = ReflectionParser.parse(raw)
assertNotNull(p)
assertEquals(3, p.score)
assertEquals(listOf<String>(), p.weakSpots)
}
@Test
fun `parses JSON with leading and trailing text`() {
val raw = "Вот мой ответ:\n" +
"{\"score\": 2, \"summary\": \"плохо\", \"weakSpots\": [\"путаю\", \"медленно\"]}\n" +
"Конец."
val p = ReflectionParser.parse(raw)
assertNotNull(p)
assertEquals(2, p.score)
assertEquals(listOf("путаю", "медленно"), p.weakSpots)
}
@Test
fun `accepts score as string`() {
val raw = """{"score": "5", "summary": "ok", "weakSpots": []}"""
val p = ReflectionParser.parse(raw)
assertNotNull(p)
assertEquals(5, p.score)
}
@Test
fun `returns null on missing score`() {
val raw = """{"summary": "x", "weakSpots": []}"""
assertNull(ReflectionParser.parse(raw))
}
@Test
fun `returns null on invalid JSON`() {
assertNull(ReflectionParser.parse("not even json"))
}
@Test
fun `handles escape sequences in weakSpots`() {
// raw содержит 4 backslashes подряд; парсер \\ → \, итого 2 backslashes в результате
val raw = """{"score": 3, "summary": "ok", "weakSpots": ["path\\\\file"]}"""
val p = ReflectionParser.parse(raw)
assertNotNull(p)
// парсер снимает один escape: \\\\ → \\
assertEquals(listOf("path\\\\file"), p.weakSpots)
}
}
@@ -1,66 +0,0 @@
package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.memory.ConversationTurn
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import pw.binom.agentik.llm.tools.SkillMiner
class SkillMinerTest {
private fun turns(n: Int): List<ConversationTurn> = (1..n).map {
ConversationTurn(userMessage = "q$it", assistantMessage = "a$it")
}
@Test
fun `mine returns parsed skills from model JSON reply`() = runBlocking {
val llm = FakeLiteLlm()
llm.reply = """{"skills": [{"name": "n", "description": "d", "body": "b"}]}"""
val miner = SkillMiner(llm, maxTurns = 10, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
val out = miner.mine(turns(5), existing = emptyList())
assertEquals(1, out.size)
assertEquals("n", out[0].name)
}
@Test
fun `mine with empty reply returns empty`() = runBlocking {
val llm = FakeLiteLlm()
llm.reply = "{}"
val miner = SkillMiner(llm, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
assertTrue(miner.mine(turns(5), emptyList()).isEmpty())
}
@Test
fun `miner failure degrades to empty list`() = runBlocking {
val llm = FakeLiteLlm()
llm.failMessage = "onnx died"
val miner = SkillMiner(llm, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
assertTrue(miner.mine(turns(5), emptyList()).isEmpty())
}
@Test
fun `miner respects maxTurns`() = runBlocking {
val llm = FakeLiteLlm()
llm.reply = """{"skills": []}"""
val miner = SkillMiner(llm, maxTurns = 2, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
miner.mine(turns(30), emptyList())
val prompt = llm.lastContents!!.first().let {
val p = it as pw.binom.litert.LiteContentPart.Text
p.text
}
// В промпт попало только последние 2 хода из 30.
assertTrue(prompt.contains("q29"), "last turns missing: $prompt")
assertTrue(!prompt.contains("q1\n"), "old turn leaked: $prompt")
}
@Test
fun `empty turns short-circuit without LLM call`() = runBlocking {
val llm = FakeLiteLlm()
llm.failMessage = "should not be called"
val miner = SkillMiner(llm, dispatcher = kotlinx.coroutines.Dispatchers.Unconfined)
val out = miner.mine(emptyList(), emptyList())
assertTrue(out.isEmpty())
assertTrue(llm.conversations.isEmpty(), "LLM must not be called for empty input")
}
}
@@ -1,82 +0,0 @@
package pw.binom.agentik.standalone.agent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import pw.binom.agentik.llm.tools.SkillMiningParser
class SkillMiningParserTest {
@Test
fun `parses clean JSON`() {
val raw = """{"skills": [{"name": "backend:spring:db", "description": "x", "body": "# step 1"}]}"""
val out = SkillMiningParser.parse(raw)
assertEquals(1, out.size)
assertEquals("backend:spring:db", out[0].name)
assertEquals("x", out[0].description)
assertEquals("# step 1", out[0].body)
}
@Test
fun `parses JSON wrapped in fences with prose around`() {
val raw = "Окей, вот что я нашёл:\n```json\n" +
"{\"skills\": [{\"name\": \"a\", \"description\": \"d\", \"body\": \"b\"}]}\n" +
"```\nНадеюсь, помогло."
val out = SkillMiningParser.parse(raw)
assertEquals(1, out.size)
assertEquals("a", out[0].name)
}
@Test
fun `parses bare array without skills key`() {
val raw = """[{"name": "x", "description": "d", "body": "b"}, {"name": "y"}]"""
val out = SkillMiningParser.parse(raw)
assertEquals(2, out.size)
assertEquals("x", out[0].name)
assertEquals("", out[1].body)
}
@Test
fun `unescapes newlines and quotes in body`() {
val raw = "{\"skills\": [{\"name\": \"n\", \"description\": \"\", \"body\": \"line1\\nline2\\n\\nwith \\\"quotes\\\" and backslash \\\\\\\"\"}]}"
val out = SkillMiningParser.parse(raw)
assertEquals(1, out.size)
val body = out[0].body
assertTrue(body.contains("line1\nline2"), "body: $body")
assertTrue(body.contains("\"quotes\""), "body: $body")
}
@Test
fun `empty skills array returns empty list`() {
val out = SkillMiningParser.parse("""{"skills": []}""")
assertTrue(out.isEmpty())
}
@Test
fun `model chatter with no JSON returns empty`() {
val out = SkillMiningParser.parse("Скилов не нашёл, всё чисто.")
assertTrue(out.isEmpty())
}
@Test
fun `truncated JSON returns empty`() {
val out = SkillMiningParser.parse("""{"skills": [{"name": "a", "description": "d", "body": """"")
assertTrue(out.isEmpty())
}
@Test
fun `skills without name are dropped`() {
val raw = """{"skills": [{"description": "no name"}, {"name": "ok"}]}"""
val out = SkillMiningParser.parse(raw)
assertEquals(1, out.size)
assertEquals("ok", out[0].name)
}
@Test
fun `nested braces inside strings do not break balance`() {
val raw = """{"skills": [{"name": "n", "description": "d", "body": "echo '{\"k\": 1}'"}]}"""
val out = SkillMiningParser.parse(raw)
assertEquals(1, out.size)
assertEquals("echo '{\"k\": 1}'", out[0].body)
}
}
@@ -1,65 +0,0 @@
package pw.binom.agentik.standalone.agent
import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillFile
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class SkillReadToolTest {
private val catalog = SkillCatalog(
listOf(
SkillFile(name = "lint", description = "lint things", body = "# Lint\nRun the linter."),
SkillFile(name = "empty", description = "no body", body = ""),
),
)
private val tool = SkillReadTool(catalog)
@Test
fun describeIsFlatOpenApiSchema() {
val json = tool.describe()
// Flat OpenAPI-спец (формат LiteRT-LM): name/description/parameters на верхнем уровне.
assertTrue("\"name\"" in json)
assertTrue(SkillReadTool.NAME in json)
assertTrue("parameters" in json)
assertTrue(!("\"type\":\"function\"" in json || "\"type\": \"function\"" in json))
}
@Test
fun invokeReturnsBodyForKnownSkill() {
val result = tool.invoke("""{"name":"lint"}""")
assertEquals("# Lint\nRun the linter.", result)
}
@Test
fun invokeUnknownSkillListsAvailable() {
val result = tool.invoke("""{"name":"nope"}""")
assertTrue("unknown skill" in result)
assertTrue("lint" in result)
assertTrue("empty" in result)
}
@Test
fun invokeEmptyBodyGivesPlaceholder() {
val result = tool.invoke("""{"name":"empty"}""")
assertTrue("empty body" in result)
}
@Test
fun invokeMissingNameIsError() {
val result = tool.invoke("{}")
assertTrue("[tool error]" in result)
assertTrue("name" in result)
}
@Test
fun invokeInvalidJsonIsError() {
assertTrue("[tool error]" in tool.invoke("not json"))
}
@Test
fun invokeBlankArgumentsIsError() {
assertTrue("[tool error]" in tool.invoke(""))
}
}
@@ -7,6 +7,8 @@ import java.io.File
import kotlin.uuid.Uuid
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.skills.DiskSkillStore
import pw.binom.agentik.skill.mining.SkillSaveTool
import pw.binom.agentik.skill.mining.SkillDeleteTool
import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillFile
import pw.binom.agentik.skills.SkillStore
@@ -1,186 +0,0 @@
package pw.binom.agentik.standalone.agent.memory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemorySource
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemoryStoreEvent
import pw.binom.agentik.memory.ReviewedTurn
import pw.binom.agentik.standalone.agent.FakeLiteLlm
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Instant
import pw.binom.agentik.llm.tools.LlmMemoryReviewer
import pw.binom.agentik.llm.tools.ReviewPrompts
class LlmMemoryReviewerTest {
/**
* Минимальный in-memory store для тестов — реализует [MemoryStore],
* хранит заметки в MutableList, поддерживает events flow.
*/
private class InMemoryStore : MemoryStore {
private val notes = mutableMapOf<String, MemoryNote>()
private val _events = kotlinx.coroutines.flow.MutableSharedFlow<MemoryStoreEvent>(extraBufferCapacity = 16)
override suspend fun upsert(note: MemoryNote) {
notes[note.id] = note
_events.emit(MemoryStoreEvent.Upserted(note))
}
override suspend fun get(id: String): MemoryNote? = notes[id]
override suspend fun list(
category: MemoryCategory?,
conversationId: String?,
limit: Int,
offset: Int,
): List<MemoryNote> = notes.values
.filter { category == null || it.category == category }
.filter { conversationId == null || it.conversationId == conversationId }
.sortedByDescending { it.lastUsedAt }
.drop(offset)
.take(limit)
override suspend fun search(query: pw.binom.agentik.memory.MemorySearchQuery): List<pw.binom.agentik.memory.MemorySearchResult> = emptyList()
override suspend fun delete(id: String): Boolean = notes.remove(id) != null
override suspend fun markUsed(id: String, at: Instant) {
notes[id]?.let {
notes[id] = it.copy(lastUsedAt = at, useCount = it.useCount + 1)
}
}
override fun events(): kotlinx.coroutines.flow.Flow<MemoryStoreEvent> = _events
override fun close() {}
// Helper for tests to seed notes
fun seed(note: MemoryNote) {
notes[note.id] = note
}
}
@Test
fun `review parses save JSON and applies upsert`() = runTest {
val llm = FakeLiteLlm().apply {
reply = """{"save":[{"category":"USER","content":"Имя — Саша"}],"delete":[]}"""
}
val store = InMemoryStore()
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
val decision = reviewer.review(
ReviewedTurn(
userMessage = "Меня Саша зовут",
assistantMessage = "Приятно познакомиться, Саша!",
)
)
assertEquals(1, decision.toSave.size)
assertEquals(MemoryCategory.USER, decision.toSave[0].category)
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
assertEquals(1, applied.saved)
val all = store.list()
assertEquals(1, all.size)
assertEquals("Имя — Саша", all[0].content)
assertEquals(MemorySource.AUTO_REVIEW, all[0].source)
}
@Test
fun `review applies delete decisions`() = runTest {
val llm = FakeLiteLlm().apply {
reply = """{"save":[],"delete":["mem-stale-1"]}"""
}
val store = InMemoryStore().apply {
seed(
MemoryNote(
id = "mem-stale-1",
category = MemoryCategory.USER,
content = "stale",
createdAt = Instant.parse("2026-01-01T00:00:00Z"),
lastUsedAt = Instant.parse("2026-01-01T00:00:00Z"),
useCount = 0,
source = MemorySource.AUTO_REVIEW,
)
)
}
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
val decision = reviewer.review(ReviewedTurn("удали это", "ок"))
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
assertEquals(0, applied.saved)
assertEquals(1, applied.deleted)
assertEquals(0, store.list().size)
}
@Test
fun `review returns empty decision when LLM produces garbage`() = runTest {
val llm = FakeLiteLlm().apply { reply = "Извини, я не могу помочь с этим." }
val store = InMemoryStore()
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
val decision = reviewer.review(ReviewedTurn("hi", "hello"))
assertTrue(decision.toSave.isEmpty())
assertTrue(decision.toDelete.isEmpty())
}
@Test
fun `review handles empty LLM reply`() = runTest {
val llm = FakeLiteLlm().apply { reply = "" }
val store = InMemoryStore()
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
val decision = reviewer.review(ReviewedTurn("hi", "hello"))
assertTrue(decision.toSave.isEmpty())
}
@Test
fun `review creates conversation with review system prompt`() = runTest {
val llm = FakeLiteLlm().apply {
reply = """{"save":[],"delete":[]}"""
}
val store = InMemoryStore()
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
reviewer.review(ReviewedTurn("u", "a"))
assertNotNull(llm.lastConfig)
assertEquals(ReviewPrompts.REVIEW_SYSTEM_PROMPT, llm.lastConfig!!.systemInstruction)
}
@Test
fun `reviewPreCompaction processes batch of turns`() = runTest {
val llm = FakeLiteLlm().apply {
reply = """
{"save":[
{"category":"USER","content":"Работает в Яндексе"},
{"category":"WORLD","content":"JVector — pure-Java ANN"}
],"delete":[]}
""".trimIndent()
}
val store = InMemoryStore()
val reviewer = LlmMemoryReviewer(llm, store, Dispatchers.Unconfined)
val turns = listOf(
pw.binom.agentik.memory.ConversationTurn(
userMessage = "Я в Яндексе работаю",
assistantMessage = "Круто!",
),
pw.binom.agentik.memory.ConversationTurn(
userMessage = "А что за JVector?",
assistantMessage = "ANN-библиотека на Java.",
),
)
val decision = reviewer.reviewPreCompaction(turns)
val applied = reviewer.apply(decision, MemorySource.AUTO_REVIEW)
assertEquals(2, applied.saved)
assertEquals(2, store.list().size)
}
}
@@ -1,107 +0,0 @@
package pw.binom.agentik.standalone.agent.memory
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryReviewDecision
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import pw.binom.agentik.llm.tools.ReviewDecisionParser
class ReviewDecisionParserTest {
@Test
fun `parses save array with USER category`() {
val raw = """{"save":[{"category":"USER","content":"Имя пользователя — Саша"}],"delete":[]}"""
val decision = ReviewDecisionParser.parse(raw)
assertEquals(1, decision.toSave.size)
assertEquals(MemoryCategory.USER, decision.toSave[0].category)
assertEquals("Имя пользователя — Саша", decision.toSave[0].content)
assertTrue(decision.toDelete.isEmpty())
}
@Test
fun `parses all three categories`() {
val raw = """
{"save":[
{"category":"USER","content":"Работает в Яндексе"},
{"category":"WORLD","content":"JVector — pure-Java ANN от DataStax"},
{"category":"PREFERENCE","content":"Отвечать кратко"}
],"delete":[]}
""".trimIndent()
val decision = ReviewDecisionParser.parse(raw)
assertEquals(3, decision.toSave.size)
assertEquals(MemoryCategory.USER, decision.toSave[0].category)
assertEquals(MemoryCategory.WORLD, decision.toSave[1].category)
assertEquals(MemoryCategory.PREFERENCE, decision.toSave[2].category)
}
@Test
fun `parses delete array with ids`() {
val raw = """{"save":[],"delete":["mem-123","mem-456"]}"""
val decision = ReviewDecisionParser.parse(raw)
assertTrue(decision.toSave.isEmpty())
assertEquals(listOf("mem-123", "mem-456"), decision.toDelete)
}
@Test
fun `returns empty decision on empty input`() {
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(""))
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(" "))
}
@Test
fun `returns empty decision on non-JSON garbage`() {
val raw = "Извини, я не могу помочь с этим."
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw))
}
@Test
fun `returns empty decision on malformed JSON`() {
val raw = """{"save":[{"category":"USER","content":"foo""" // truncated
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw))
}
@Test
fun `extracts JSON from markdown code block`() {
val raw = """
Вот JSON:
```json
{"save":[{"category":"WORLD","content":"SQLite 3.51"}],"delete":[]}
```
""".trimIndent()
val decision = ReviewDecisionParser.parse(raw)
assertEquals(1, decision.toSave.size)
assertEquals(MemoryCategory.WORLD, decision.toSave[0].category)
assertEquals("SQLite 3.51", decision.toSave[0].content)
}
@Test
fun `skips entries with unknown category`() {
val raw = """{"save":[
{"category":"USER","content":"valid"},
{"category":"NOT_A_CATEGORY","content":"should be skipped"},
{"category":"WORLD","content":"valid too"}
],"delete":[]}"""
val decision = ReviewDecisionParser.parse(raw)
assertEquals(2, decision.toSave.size)
assertEquals("valid", decision.toSave[0].content)
assertEquals("valid too", decision.toSave[1].content)
}
@Test
fun `skips entries with blank content`() {
val raw = """{"save":[
{"category":"USER","content":""},
{"category":"WORLD","content":" "}
],"delete":[]}"""
assertEquals(MemoryReviewDecision(), ReviewDecisionParser.parse(raw))
}
@Test
fun `handles escaped quotes in content`() {
val raw = """{"save":[{"category":"USER","content":"Сказал \"привет\""}],"delete":[]}"""
val decision = ReviewDecisionParser.parse(raw)
assertEquals(1, decision.toSave.size)
assertEquals("Сказал \"привет\"", decision.toSave[0].content)
}
}
@@ -5,7 +5,6 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertSame
import kotlin.test.assertTrue
import pw.binom.agentik.toolsets.NamedTool
import pw.binom.agentik.mcp.bridge.McpConfig
import pw.binom.agentik.mcp.bridge.McpRegistry
@@ -16,28 +15,26 @@ class McpRegistryTest {
val registry = McpRegistry.fromConfig(McpConfig.empty())
assertEquals(0, registry.allTools.size)
assertEquals(0, registry.connectedServerCount)
assertEquals(emptyList(), registry.namedTools)
registry.close()
}
@Test
fun `empty servers list produces empty registry`() {
val registry = McpRegistry(servers = emptyList())
assertEquals(0, registry.namedTools.size)
assertEquals(0, registry.allTools.size)
registry.close()
}
@Test
fun `NamedTool holds name and tool reference`() {
fun `LiteTool name is exposed`() {
val noop: LiteTool = object : LiteTool {
override val name: String = "server__echo"
override fun describe(): String = "{}"
override fun invoke(arguments: String): String = ""
}
val nt = NamedTool(name = "server__echo", tool = noop)
assertEquals("server__echo", nt.name)
assertSame(noop, nt.tool)
assertEquals("{}", nt.tool.describe())
assertEquals("server__echo", noop.name)
assertSame(noop, noop)
assertEquals("{}", noop.describe())
}
@Test
@@ -8,7 +8,7 @@ import pw.binom.agentik.context.WorkingMemoryEntry
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.storage.ksqlite.KsqliteStores
import pw.binom.agentik.standalone.persistence.SqliteStores
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
@@ -22,11 +22,11 @@ import kotlin.time.Instant
class PersistenceTest {
private lateinit var stores: KsqliteStores
private lateinit var stores: SqliteStores
@BeforeTest
fun setup() {
stores = KsqliteStores.inMemory("persist-${kotlin.random.Random.nextLong()}")
stores = SqliteStores.inMemory("persist-${kotlin.random.Random.nextLong()}")
}
@AfterTest