Remove :storage-inmemory module, tests, and related code.
ci / JVM build + tests (push) Successful in 6m46s
ci / JVM build + tests (push) Successful in 6m46s
This commit is contained in:
@@ -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-эндпоинты могли его триггерить вручную.
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+33
-15
@@ -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 {
|
||||
|
||||
+34
-34
@@ -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
|
||||
}
|
||||
|
||||
+2
-2
@@ -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)
|
||||
-22
@@ -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)),
|
||||
)
|
||||
}
|
||||
+4
-9
@@ -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
|
||||
|
||||
+52
@@ -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
|
||||
}
|
||||
+58
-142
@@ -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}"}"""
|
||||
}
|
||||
|
||||
+10
-21
@@ -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) }
|
||||
}
|
||||
|
||||
+36
@@ -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()
|
||||
}
|
||||
+59
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+11
-12
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+48
-38
@@ -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"}""")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+16
-6
@@ -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 {
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+28
@@ -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
|
||||
}
|
||||
+8
-8
@@ -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"),
|
||||
|
||||
-71
@@ -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")
|
||||
}
|
||||
}
|
||||
-82
@@ -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)
|
||||
}
|
||||
}
|
||||
-65
@@ -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
|
||||
|
||||
-186
@@ -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)
|
||||
}
|
||||
}
|
||||
-107
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user