remove pw.binom.agentik.standalone.agent and memory modules along with related utilities, tools, and background processing
ci / JVM build + tests (push) Failing after 59s
ci / JVM build + tests (push) Failing after 59s
This commit is contained in:
+22
-36
@@ -1,10 +1,9 @@
|
|||||||
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
|
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
|
||||||
|
import org.gradle.api.artifacts.ConfigurationContainer
|
||||||
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||||
import org.jetbrains.kotlin.gradle.targets.jvm.KotlinJvmTarget
|
|
||||||
|
|
||||||
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||||
import org.gradle.api.artifacts.ConfigurationContainer
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.multiplatform)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
@@ -19,16 +18,15 @@ val skipVectorMemory: Boolean =
|
|||||||
(project.findProperty("skipVectorMemory") == "true") ||
|
(project.findProperty("skipVectorMemory") == "true") ||
|
||||||
System.getenv("SKIP_VECTOR_MEMORY") == "1"
|
System.getenv("SKIP_VECTOR_MEMORY") == "1"
|
||||||
|
|
||||||
|
// :standalone — единственный entry-point проекта, JVM-only по факту (один
|
||||||
|
// `jvm()` target). Чтобы не таскать лишние sourceSet'ы, ВСЁ живёт в
|
||||||
|
// commonMain + commonTest — даже зависимости, которые формально JVM-only
|
||||||
|
// (ktor-server-cio, litert-openai, storage-ksqlite, ...). KMP-плагин тут
|
||||||
|
// только ради бесшовного потребления KMP-зависимостей (`:proto`, `:server`,
|
||||||
|
// `:journal-api`, ...); компилируется всё ровно в одну JVM-таргет.
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
|
|
||||||
// Отключаем auto-propagation Apple-таргетов от KMP-зависимостей (`:proto`,
|
|
||||||
// `:server`, `:agent-toolsets` имеют Apple-варианты). `:standalone` JVM-only,
|
|
||||||
// Apple таргеты не нужны и ломают резолюцию `:message-store-api` в appleMain
|
|
||||||
// (он KMP jvm+linuxX64+mingwX64 без Apple).
|
|
||||||
// @OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
|
|
||||||
// applyDefaultHierarchyTemplate { }
|
|
||||||
|
|
||||||
jvm {
|
jvm {
|
||||||
binaries {
|
binaries {
|
||||||
executable {
|
executable {
|
||||||
@@ -39,6 +37,7 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
|
// Протокол + KMP storage API
|
||||||
implementation(project(":proto"))
|
implementation(project(":proto"))
|
||||||
implementation(project(":server"))
|
implementation(project(":server"))
|
||||||
implementation(project(":journal-api"))
|
implementation(project(":journal-api"))
|
||||||
@@ -50,14 +49,6 @@ kotlin {
|
|||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
// litert-kmp: контракт (commonMain)
|
|
||||||
api(libs.litert.api)
|
|
||||||
|
|
||||||
// litert-openai: JVM-реализация
|
|
||||||
implementation(libs.litert.openai)
|
|
||||||
}
|
|
||||||
|
|
||||||
jvmMain.dependencies {
|
|
||||||
// Парсер и загрузчик скилов (YAML frontmatter + markdown body).
|
// Парсер и загрузчик скилов (YAML frontmatter + markdown body).
|
||||||
implementation(project(":skills"))
|
implementation(project(":skills"))
|
||||||
|
|
||||||
@@ -67,24 +58,25 @@ kotlin {
|
|||||||
if (!skipVectorMemory) {
|
if (!skipVectorMemory) {
|
||||||
implementation(project(":memory-vector"))
|
implementation(project(":memory-vector"))
|
||||||
}
|
}
|
||||||
implementation(project(":journal-api"))
|
|
||||||
implementation(project(":outbox-api"))
|
// SQLite-бэкенд (ksqlite JNI) — JVM-only по факту.
|
||||||
implementation(project(":context-api"))
|
|
||||||
implementation(project(":reflection-api"))
|
|
||||||
implementation(project(":storage-ksqlite"))
|
implementation(project(":storage-ksqlite"))
|
||||||
|
|
||||||
// Новый единый канал событий агента — заменил старые
|
// Bounded-tail live event stream + per-event TTL.
|
||||||
// `agentEvents: MutableSharedFlow<AgentEvent>` и per-conv `ConversationEvents._flow`.
|
|
||||||
// Bounded tail + auto-TTL, generic CommonEvent envelope.
|
|
||||||
implementation(project(":outbox-inmemory"))
|
implementation(project(":outbox-inmemory"))
|
||||||
|
|
||||||
|
// LLM-toolsets + generic LLM-side tools (LlmReflector, SkillMiner,
|
||||||
|
// LlmMemoryReviewer, ContextCompactor, парсеры/промпты).
|
||||||
implementation(project(":agent-toolsets"))
|
implementation(project(":agent-toolsets"))
|
||||||
// Generic LLM-side tools (LlmReflector, SkillMiner, LlmMemoryReviewer,
|
|
||||||
// ContextCompactor, парсеры/промпты). Вынесены из :standalone.
|
|
||||||
implementation(project(":llm-tools"))
|
implementation(project(":llm-tools"))
|
||||||
|
|
||||||
// Generic MCP-bridge (McpConfig, McpRegistry, McpLiteToolAdapter).
|
// Generic MCP-bridge (McpConfig, McpRegistry, McpLiteToolAdapter).
|
||||||
// Вынесен из :standalone — generic мост между MCP-SDK и LiteTool.
|
|
||||||
implementation(project(":mcp-bridge"))
|
implementation(project(":mcp-bridge"))
|
||||||
|
|
||||||
|
// litert-kmp: контракт (commonMain)
|
||||||
|
api(libs.litert.api)
|
||||||
|
// litert-openai: JVM-реализация
|
||||||
|
implementation(libs.litert.openai)
|
||||||
// litert-google: встроенный LiteRT-LM движок, нужен только на runtime
|
// litert-google: встроенный LiteRT-LM движок, нужен только на runtime
|
||||||
runtimeOnly(libs.litert.google)
|
runtimeOnly(libs.litert.google)
|
||||||
|
|
||||||
@@ -98,7 +90,7 @@ kotlin {
|
|||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
|
||||||
// Транспортные фасады (A2A остаётся заготовкой; на v1 не подключается в Main.kt —
|
// Транспортные фасады (A2A остаётся заготовкой; на v1 не подключается в Main.kt —
|
||||||
// см. docs/ARCHITECTURE.md §3).
|
// см. docs/ARCHITECTURE.md §3).
|
||||||
implementation(libs.a2a.server)
|
implementation(libs.a2a.server)
|
||||||
|
|
||||||
// MCP (Model Context Protocol) клиент — подключение внешних/внутренних MCP-серверов
|
// MCP (Model Context Protocol) клиент — подключение внешних/внутренних MCP-серверов
|
||||||
@@ -106,7 +98,6 @@ kotlin {
|
|||||||
implementation(libs.ktor.client.core)
|
implementation(libs.ktor.client.core)
|
||||||
implementation(libs.ktor.client.cio)
|
implementation(libs.ktor.client.cio)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
|
||||||
|
|
||||||
// Логирование: kotlin-logging (тонкая обёртка slf4j-api) + logback-classic
|
// Логирование: kotlin-logging (тонкая обёртка slf4j-api) + logback-classic
|
||||||
// (binding для JVM; без него slf4j-api NOP-логирует и не падает).
|
// (binding для JVM; без него slf4j-api NOP-логирует и не падает).
|
||||||
@@ -115,11 +106,6 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
|
||||||
implementation(libs.kotlin.test)
|
|
||||||
}
|
|
||||||
|
|
||||||
jvmTest.dependencies {
|
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.kotlin.test)
|
implementation(libs.kotlin.test)
|
||||||
@@ -161,11 +147,11 @@ val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
|||||||
// из любого типа, который сейчас прикидывается `configurations`.
|
// из любого типа, который сейчас прикидывается `configurations`.
|
||||||
val cc = try {
|
val cc = try {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
configurations as org.gradle.api.artifacts.ConfigurationContainer
|
configurations as ConfigurationContainer
|
||||||
} catch (_: ClassCastException) {
|
} catch (_: ClassCastException) {
|
||||||
// KGP-generated dependency accessor; обходим через raw project.
|
// KGP-generated dependency accessor; обходим через raw project.
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
(project as org.gradle.api.Project).configurations as org.gradle.api.artifacts.ConfigurationContainer
|
(project as org.gradle.api.Project).configurations as ConfigurationContainer
|
||||||
}
|
}
|
||||||
from(cc.getByName("jvmRuntimeClasspath"))
|
from(cc.getByName("jvmRuntimeClasspath"))
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!--
|
|
||||||
Logback-конфиг для standalone.
|
|
||||||
Уровень управляется env var AGENTIK_LOG_LEVEL (default INFO).
|
|
||||||
Формат: timestamp [level] [thread] logger — message
|
|
||||||
-->
|
|
||||||
<configuration>
|
|
||||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
|
||||||
<encoder>
|
|
||||||
<pattern>%d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n</pattern>
|
|
||||||
</encoder>
|
|
||||||
</appender>
|
|
||||||
|
|
||||||
<!-- Default уровень — INFO. Можно перебить через env: AGENTIK_LOG_LEVEL=DEBUG -->
|
|
||||||
<root level="${AGENTIK_LOG_LEVEL:-INFO}">
|
|
||||||
<appender-ref ref="STDOUT"/>
|
|
||||||
</root>
|
|
||||||
|
|
||||||
<!-- Шумные библиотеки уводим в WARN. -->
|
|
||||||
<logger name="io.netty" level="WARN"/>
|
|
||||||
<logger name="io.ktor" level="INFO"/>
|
|
||||||
<logger name="ai.onnxruntime" level="WARN"/>
|
|
||||||
</configuration>
|
|
||||||
-74
@@ -1,74 +0,0 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
class ChatAgentReflectionTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
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"))
|
|
||||||
assertFalse(prompt.contains("слабые места"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `buildSystemPrompt includes section with weak spots when reflections non-empty`() {
|
|
||||||
val r = Reflection(
|
|
||||||
id = "r1",
|
|
||||||
conversationId = null,
|
|
||||||
createdAt = Instant.parse("2026-09-15T12:00:00Z"),
|
|
||||||
turnsAnalyzed = 10,
|
|
||||||
score = 2,
|
|
||||||
summary = "плохо",
|
|
||||||
weakSpots = listOf("медленно отвечаю на X", "путаю A и B"),
|
|
||||||
)
|
|
||||||
val prompt = buildSystemPrompt(
|
|
||||||
base = "base",
|
|
||||||
skills = SkillCatalog.EMPTY,
|
|
||||||
memoryEnabled = false,
|
|
||||||
soulBody = null,
|
|
||||||
reflections = listOf(r),
|
|
||||||
)
|
|
||||||
assertTrue(prompt.contains("Self-reflection"))
|
|
||||||
assertTrue(prompt.contains("слабые места"))
|
|
||||||
assertTrue(prompt.contains("медленно отвечаю на X"))
|
|
||||||
assertTrue(prompt.contains("путаю A и B"))
|
|
||||||
assertTrue(prompt.contains("score=2/5"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `buildSystemPrompt places reflection section after memory and before soul prepend`() {
|
|
||||||
val r = Reflection(
|
|
||||||
id = "r1",
|
|
||||||
conversationId = null,
|
|
||||||
createdAt = Instant.parse("2026-09-15T12:00:00Z"),
|
|
||||||
turnsAnalyzed = 5,
|
|
||||||
score = 4,
|
|
||||||
summary = "ok",
|
|
||||||
weakSpots = listOf("minor issue"),
|
|
||||||
)
|
|
||||||
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")
|
|
||||||
val reflIdx = prompt.indexOf("Self-reflection")
|
|
||||||
assertTrue(soulIdx >= 0 && reflIdx >= 0)
|
|
||||||
assertTrue(soulIdx < reflIdx, "soul должен идти перед reflection")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,639 +0,0 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
|
||||||
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.toList
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
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.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.litert.LiteContentPart
|
|
||||||
import pw.binom.litert.LiteConversation
|
|
||||||
import pw.binom.litert.LiteConversationConfig
|
|
||||||
import pw.binom.litert.LiteDelta
|
|
||||||
import pw.binom.litert.LiteLlm
|
|
||||||
import pw.binom.litert.LiteMessage
|
|
||||||
import pw.binom.litert.LiteRole
|
|
||||||
import pw.binom.litert.LiteTool
|
|
||||||
import pw.binom.litert.LiteToolCall
|
|
||||||
import pw.binom.agentik.standalone.llm.OpenAiConfig
|
|
||||||
import kotlin.test.AfterTest
|
|
||||||
import kotlin.test.BeforeTest
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertFalse
|
|
||||||
import kotlin.test.assertIs
|
|
||||||
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 fakeLlm: FakeLiteLlm
|
|
||||||
|
|
||||||
@BeforeTest
|
|
||||||
fun setup() {
|
|
||||||
sqliteStores = KsqliteStores.inMemory("chat-${kotlin.random.Random.nextLong()}")
|
|
||||||
fakeLlm = FakeLiteLlm()
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterTest
|
|
||||||
fun tearDown() {
|
|
||||||
sqliteStores.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun newAgent(
|
|
||||||
sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores = this.sqliteStores,
|
|
||||||
llm: LiteLlm = this.fakeLlm,
|
|
||||||
tools: List<NamedTool> = emptyList(),
|
|
||||||
skills: SkillCatalog = SkillCatalog.EMPTY,
|
|
||||||
): ChatAgent = ChatAgent(
|
|
||||||
id = "agentik",
|
|
||||||
conversationStore = 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `createConversation does NOT seed system prompt into working memory`() = runTest {
|
|
||||||
// System prompt живёт ТОЛЬКО in-memory в ChatConversation.systemPrompt
|
|
||||||
// и едет в LLM через LiteConversationConfig.systemInstruction. В
|
|
||||||
// working_memory ничего не пишется — старт system prompt чистый,
|
|
||||||
// 0 entries.
|
|
||||||
val agent = newAgent()
|
|
||||||
val conv = agent.createConversation(temp = false) as ChatConversation
|
|
||||||
|
|
||||||
val wm = sqliteStores.workingMemory.list(conv.id)
|
|
||||||
assertEquals(0, wm.size)
|
|
||||||
// System prompt виден через LiteConversationConfig, который LLM получит
|
|
||||||
// при первом send (см. `send passes system prompt and past history to LLM on first send`).
|
|
||||||
}
|
|
||||||
|
|
||||||
@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(
|
|
||||||
listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")),
|
|
||||||
)
|
|
||||||
val agent = newAgent(skills = skills)
|
|
||||||
val conv = agent.createConversation(temp = false)
|
|
||||||
fakeLlm.reply = "ok"
|
|
||||||
conv.send(listOf(Content.Text("hi")))
|
|
||||||
|
|
||||||
val system = fakeLlm.lastConfig!!.systemInstruction
|
|
||||||
assertNotNull(system)
|
|
||||||
assertTrue("be brief" in system!!, "base prompt missing: $system")
|
|
||||||
assertTrue("## Навыки" in system, "skills section missing: $system")
|
|
||||||
assertTrue("lint" in system, "skill name missing: $system")
|
|
||||||
assertTrue("lint things" in system, "skill description missing: $system")
|
|
||||||
assertFalse("SECRET BODY" in system, "system prompt must not leak the skill body")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `read_skill tool is registered when skills present`() = runTest {
|
|
||||||
val skills = SkillCatalog(
|
|
||||||
listOf(SkillFile(name = "lint", description = "lint things", body = "SECRET BODY")),
|
|
||||||
)
|
|
||||||
val agent = newAgent(skills = skills)
|
|
||||||
val conv = agent.createConversation(temp = false)
|
|
||||||
fakeLlm.reply = "ok"
|
|
||||||
conv.send(listOf(Content.Text("hi")))
|
|
||||||
|
|
||||||
val descriptors = fakeLlm.lastConfig!!.tools.map { it.describe() }
|
|
||||||
assertTrue(descriptors.any { SkillReadTool.NAME in it }, "expected read_skill tool: $descriptors")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `no read_skill tool when skills absent`() = runTest {
|
|
||||||
val agent = newAgent()
|
|
||||||
val conv = agent.createConversation(temp = false)
|
|
||||||
fakeLlm.reply = "ok"
|
|
||||||
conv.send(listOf(Content.Text("hi")))
|
|
||||||
|
|
||||||
val descriptors = fakeLlm.lastConfig?.tools?.map { it.describe() } ?: emptyList()
|
|
||||||
assertTrue(descriptors.none { SkillReadTool.NAME in it }, "unexpected read_skill tool: $descriptors")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `getConversation returns null for unknown id`() = runTest {
|
|
||||||
val agent = newAgent()
|
|
||||||
assertNull(agent.getConversation("nope"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `getConversations returns all stored persistent conversations`() = runTest {
|
|
||||||
val agent = newAgent()
|
|
||||||
agent.createConversation(temp = false)
|
|
||||||
agent.createConversation(temp = true)
|
|
||||||
val list = agent.getConversations(0, 10)
|
|
||||||
// temp-беседы не персистятся, в списке только persistent
|
|
||||||
assertEquals(1, list.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `deleteConversation removes conversation and data`() = runTest {
|
|
||||||
val agent = newAgent()
|
|
||||||
val conv = agent.createConversation(temp = false)
|
|
||||||
val id = conv.id
|
|
||||||
|
|
||||||
// добавим сообщение, чтобы потом убедиться, что каскад сработал
|
|
||||||
sqliteStores.messages.append(
|
|
||||||
pw.binom.agentik.journal.MessageRecord.UserMessage(
|
|
||||||
id = "m1",
|
|
||||||
conversationId = id,
|
|
||||||
content = listOf(pw.binom.agentik.journal.Content.Text("hi")),
|
|
||||||
createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertTrue(agent.deleteConversation(id))
|
|
||||||
assertNull(agent.getConversation(id))
|
|
||||||
assertNull(sqliteStores.conversations.get(id))
|
|
||||||
assertEquals(emptyList(), sqliteStores.messages.listFlow(id, Instant.DISTANT_PAST).toList())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `deleteConversation returns false for unknown id`() = runTest {
|
|
||||||
val agent = newAgent()
|
|
||||||
assertEquals(false, agent.deleteConversation("nope"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `send emits start_reasoning, start_response, append_text, end`() = runTest {
|
|
||||||
val agent = newAgent()
|
|
||||||
fakeLlm.reply = "hello back"
|
|
||||||
val conv = agent.createConversation(temp = false)
|
|
||||||
|
|
||||||
conv.send(listOf(Content.Text("hi")))
|
|
||||||
|
|
||||||
// user message записан в audit + working memory
|
|
||||||
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
|
|
||||||
assertEquals(2, msgs.size)
|
|
||||||
assertEquals("hi", (msgs[0] as pw.binom.agentik.journal.MessageRecord.UserMessage).content.let {
|
|
||||||
(it[0] as pw.binom.agentik.journal.Content.Text).body
|
|
||||||
})
|
|
||||||
assertEquals("hello back", (msgs[1] as pw.binom.agentik.journal.MessageRecord.AssistantMessage).content.let {
|
|
||||||
(it[0] as pw.binom.agentik.journal.Content.Text).body
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `send reconstructs conversation history from working memory`() = runTest {
|
|
||||||
val agent = newAgent()
|
|
||||||
fakeLlm.rememberHistory = true
|
|
||||||
fakeLlm.reply = "first reply"
|
|
||||||
val conv1 = agent.createConversation(temp = false)
|
|
||||||
conv1.send(listOf(Content.Text("first user")))
|
|
||||||
|
|
||||||
// Новая беседа не должна видеть историю первой
|
|
||||||
val conv2 = agent.createConversation(temp = false)
|
|
||||||
fakeLlm.reply = "second reply"
|
|
||||||
conv2.send(listOf(Content.Text("second user")))
|
|
||||||
|
|
||||||
// В working_memory теперь НЕТ System-entries — только user + assistant.
|
|
||||||
// Системный промт живёт в ChatConversation.systemPrompt и едет в LLM
|
|
||||||
// через LiteConversationConfig.systemInstruction.
|
|
||||||
val wm1 = sqliteStores.workingMemory.list(conv1.id)
|
|
||||||
assertEquals(2, wm1.size)
|
|
||||||
val wm2 = sqliteStores.workingMemory.list(conv2.id)
|
|
||||||
assertEquals(2, wm2.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `send passes system prompt and past history to LLM on first send`() = runTest {
|
|
||||||
val agent = newAgent()
|
|
||||||
fakeLlm.rememberHistory = true
|
|
||||||
val conv = agent.createConversation(temp = false)
|
|
||||||
fakeLlm.reply = "hi"
|
|
||||||
conv.send(listOf(Content.Text("hello")))
|
|
||||||
|
|
||||||
// Длинно-живущий LiteConversation: первый send создаёт его с systemInstruction
|
|
||||||
// и пустыми initialMessages (свежее user-сообщение пойдёт через sendStreamContents).
|
|
||||||
assertNotNull(fakeLlm.lastConfig)
|
|
||||||
assertEquals("be brief", fakeLlm.lastConfig!!.systemInstruction)
|
|
||||||
assertEquals(0, fakeLlm.lastConfig!!.initialMessages.size)
|
|
||||||
// Свежее user-сообщение отправлено через sendStreamContents
|
|
||||||
assertEquals(1, fakeLlm.conversations.size)
|
|
||||||
val sent = fakeLlm.lastContents
|
|
||||||
assertNotNull(sent)
|
|
||||||
assertEquals(1, sent.size)
|
|
||||||
assertEquals("hello", (sent[0] as LiteContentPart.Text).text)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `multi-turn conversation accumulates history but recreates LiteConv each turn`() = runTest {
|
|
||||||
// Новая семантика (radical close+recreate после commit 7):
|
|
||||||
// каждый turn закрывает LiteConv и на следующем send() создаёт новую
|
|
||||||
// через getOrCreateLiteConversation, которая пересобирает initialMessages
|
|
||||||
// из working memory. То есть LiteConv — один на turn, не на диалог.
|
|
||||||
// Преимущество: interrupt можно сделать тривиально (close + cancelProcess),
|
|
||||||
// KV-cache жертвуем ради предсказуемости (~2s prefill на Gemma-4-E2B).
|
|
||||||
val agent = newAgent()
|
|
||||||
fakeLlm.rememberHistory = true
|
|
||||||
val conv = agent.createConversation(temp = false)
|
|
||||||
|
|
||||||
fakeLlm.reply = "first reply"
|
|
||||||
conv.send(listOf(Content.Text("first user")))
|
|
||||||
// первый turn: WM = [user, assistant]
|
|
||||||
assertEquals(2, sqliteStores.workingMemory.list(conv.id).size)
|
|
||||||
|
|
||||||
fakeLlm.reply = "second reply"
|
|
||||||
conv.send(listOf(Content.Text("second user")))
|
|
||||||
// второй turn: WM должен вырасти до [user, assistant, user, assistant]
|
|
||||||
val wm = sqliteStores.workingMemory.list(conv.id)
|
|
||||||
System.err.println("[TEST] wm.size=${wm.size}")
|
|
||||||
wm.forEachIndexed { i, row -> System.err.println("[TEST] $i: ${row.entry::class.simpleName} id=${row.id}") }
|
|
||||||
assertEquals(4, wm.size)
|
|
||||||
// Новая семантика: один LiteConv на turn → два LiteConv после двух send'ов.
|
|
||||||
assertEquals(2, fakeLlm.conversations.size)
|
|
||||||
// Второй LiteConv создан с initialMessages из working memory, ИСКЛЮЧАЯ pending user2
|
|
||||||
// (он передаётся в sendStreamContents, чтобы не дублироваться).
|
|
||||||
val reopened = fakeLlm.conversations.last()
|
|
||||||
assertEquals(2, reopened.initialMessages.size)
|
|
||||||
assertEquals("first user", reopened.initialMessages[0].text)
|
|
||||||
assertEquals(LiteRole.USER, reopened.initialMessages[0].role)
|
|
||||||
assertEquals("first reply", reopened.initialMessages[1].text)
|
|
||||||
assertEquals(LiteRole.MODEL, reopened.initialMessages[1].role)
|
|
||||||
// После sendStreamContents (с user2 + сгенерированный asst2) mutableHistory = 4
|
|
||||||
assertEquals(4, reopened.history.size)
|
|
||||||
assertEquals("second reply", reopened.history.last().text)
|
|
||||||
assertEquals(LiteRole.MODEL, reopened.history.last().role)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `reloaded conversation reconstructs LiteConversation from working memory`() = runTest {
|
|
||||||
val agent = newAgent()
|
|
||||||
fakeLlm.rememberHistory = true
|
|
||||||
val conv = agent.createConversation(temp = false)
|
|
||||||
fakeLlm.reply = "first reply"
|
|
||||||
conv.send(listOf(Content.Text("first user")))
|
|
||||||
val convId = conv.id
|
|
||||||
conv.close()
|
|
||||||
|
|
||||||
// Открываем новое ChatConversation с тем же id — LiteConversation должен
|
|
||||||
// быть создан заново из working memory (первый user+assistant как initial).
|
|
||||||
val reopened = agent.getConversation(convId)!!
|
|
||||||
fakeLlm.reply = "second reply"
|
|
||||||
reopened.send(listOf(Content.Text("second user")))
|
|
||||||
|
|
||||||
val allConvs = fakeLlm.conversations
|
|
||||||
assertEquals(2, allConvs.size) // original + reopened
|
|
||||||
val reopenedLite = allConvs.last()
|
|
||||||
// Initial messages: только прошлые user+assistant (НЕ включая текущий "second user")
|
|
||||||
assertEquals(2, reopenedLite.initialMessages.size)
|
|
||||||
assertEquals("first user", reopenedLite.initialMessages[0].text)
|
|
||||||
assertEquals(LiteRole.USER, reopenedLite.initialMessages[0].role)
|
|
||||||
assertEquals("first reply", reopenedLite.initialMessages[1].text)
|
|
||||||
assertEquals(LiteRole.MODEL, reopenedLite.initialMessages[1].role)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `LLM failure emits error event and persists error message`() = runTest {
|
|
||||||
val agent = newAgent()
|
|
||||||
fakeLlm.failMessage = "boom from llm"
|
|
||||||
val conv = agent.createConversation(temp = false) as ChatConversation
|
|
||||||
|
|
||||||
val events = mutableListOf<ProtoEvent>()
|
|
||||||
val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
|
||||||
conv.events(Instant.DISTANT_PAST).collect { events.add(it) }
|
|
||||||
}
|
|
||||||
conv.send(listOf(Content.Text("hi")))
|
|
||||||
delay(50)
|
|
||||||
job.cancel()
|
|
||||||
|
|
||||||
assertTrue(events.any { it is ProtoEvent.Error && it.message == "boom from llm" }, "events=$events")
|
|
||||||
assertTrue(events.any { it is ProtoEvent.End }, "events=$events")
|
|
||||||
|
|
||||||
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
|
|
||||||
assertEquals(2, msgs.size)
|
|
||||||
assertIs<pw.binom.agentik.journal.MessageRecord.UserMessage>(msgs[0])
|
|
||||||
val err = assertIs<pw.binom.agentik.journal.MessageRecord.Error>(msgs[1])
|
|
||||||
assertEquals("boom from llm", err.message)
|
|
||||||
|
|
||||||
// backfill через getMessages (polling/reconnect) тоже видит ошибку
|
|
||||||
val proto = conv.getMessages(Instant.DISTANT_PAST, offset = 0, limit = 10)
|
|
||||||
assertTrue(
|
|
||||||
proto.any { it is pw.binom.agentik.proto.Message.Error && it.message == "boom from llm" },
|
|
||||||
"history=$proto",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `interrupt mid-slow-stream preserves user message and no assistant`() = runTest {
|
|
||||||
// Новая семантика interrupt (commit 7): ставится флаг, LiteConv.cancel()
|
|
||||||
// бросает CancellationException в стриме, runTurn выходит через finally.
|
|
||||||
// Если turn не успел ничего сгенерить (reply.isEmpty() && toolExchanges.isEmpty())
|
|
||||||
// — AssistantMessage в audit log НЕ пишется. Только user + End/Interrupted.
|
|
||||||
val agent = newAgent()
|
|
||||||
fakeLlm.slow = true
|
|
||||||
val conv = agent.createConversation(temp = false)
|
|
||||||
|
|
||||||
// Подписываемся на events ДО send() — SharedFlow без replay, после
|
|
||||||
// отправки событий подписка ничего не увидит.
|
|
||||||
val events = mutableListOf<ProtoEvent>()
|
|
||||||
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
|
||||||
conv.events(Instant.DISTANT_PAST).collect { events.add(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val sendJob = launch {
|
|
||||||
try {
|
|
||||||
conv.send(listOf(Content.Text("hi")))
|
|
||||||
} catch (_: kotlinx.coroutines.CancellationException) {
|
|
||||||
// ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ждём, пока корутина дойдёт до sendStreamContents и повиснет на slow-эмиссии
|
|
||||||
delay(200)
|
|
||||||
conv.interrupt()
|
|
||||||
sendJob.join()
|
|
||||||
eventsJob.cancel()
|
|
||||||
|
|
||||||
// audit: только user (assistant не успел сгенериться)
|
|
||||||
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
|
|
||||||
assertEquals(1, msgs.size)
|
|
||||||
assertIs<pw.binom.agentik.journal.MessageRecord.UserMessage>(msgs[0])
|
|
||||||
|
|
||||||
// working memory: только user (assistant skipped because пустой)
|
|
||||||
val wm = sqliteStores.workingMemory.list(conv.id)
|
|
||||||
assertEquals(1, wm.size)
|
|
||||||
assertTrue(wm[0].entry is WorkingMemoryEntry.User)
|
|
||||||
|
|
||||||
// events: должны включать Interrupted + End
|
|
||||||
assertTrue(events.any { it is ProtoEvent.Interrupted }, "events=$events")
|
|
||||||
assertTrue(events.any { it is ProtoEvent.End }, "events=$events")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `interrupt after tool execution preserves tool result in working memory`() = runTest {
|
|
||||||
// Сценарий "LLM вызвал тул, инструмент выполнился, потом interrupt()":
|
|
||||||
// 1. LLM скриптован на ToolCalls([echo_tool])
|
|
||||||
// 2. Tool реально вызывается через toolsetDispatch.dispatch()
|
|
||||||
// 3. interrupt() приходит в окне между финальным text и завершением turn'а
|
|
||||||
//
|
|
||||||
// В audit log: user + ToolCall + ToolResult (инструмент выполнился).
|
|
||||||
// В working_memory: user + ToolExchange(result=echo output, wasCancelled=false).
|
|
||||||
// В events: ToolCall + ToolResult + Interrupted + End.
|
|
||||||
val agent = newAgent()
|
|
||||||
val conv = agent.createConversation(temp = false)
|
|
||||||
|
|
||||||
// LLM скриптован: tool call.
|
|
||||||
fakeLlm.scriptedReplies = mutableListOf(
|
|
||||||
FakeLiteLlm.Reply.ToolCalls(listOf("echo_tool" to mapOf("q" to "hi"))),
|
|
||||||
)
|
|
||||||
|
|
||||||
val echoTool = object : LiteTool {
|
|
||||||
override fun describe(): String = """{"name":"echo_tool","description":"echoes args"}"""
|
|
||||||
override fun invoke(arguments: String): String = """{"echo":$arguments}"""
|
|
||||||
}
|
|
||||||
agent.registerToolForTest("echo_tool", echoTool)
|
|
||||||
|
|
||||||
// Подписываемся ДО send — SharedFlow без replay
|
|
||||||
val events = mutableListOf<ProtoEvent>()
|
|
||||||
val eventsJob = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
|
||||||
conv.events(Instant.DISTANT_PAST).collect { events.add(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val sendJob = launch {
|
|
||||||
try {
|
|
||||||
conv.send(listOf(Content.Text("run echo tool")))
|
|
||||||
} catch (_: kotlinx.coroutines.CancellationException) {}
|
|
||||||
}
|
|
||||||
// Ждём пока инструмент выполнится (turn завершится нормально)
|
|
||||||
sendJob.join()
|
|
||||||
// interrupt() ПОСЛЕ завершения turn — не должно ничего менять в БД,
|
|
||||||
// но проверяем что events включает все ожидаемые типы.
|
|
||||||
conv.interrupt()
|
|
||||||
eventsJob.cancel()
|
|
||||||
|
|
||||||
// audit: user + toolcall + toolresult (tool выполнился), assistant может быть
|
|
||||||
val msgs = sqliteStores.messages.listFlow(conv.id, Instant.DISTANT_PAST).toList()
|
|
||||||
val toolResult = msgs.filterIsInstance<pw.binom.agentik.journal.MessageRecord.ToolResult>().firstOrNull()
|
|
||||||
assertNotNull(toolResult, "tool result должен быть в audit — tool выполнился нормально")
|
|
||||||
val toolResultResult = toolResult!!.result!!
|
|
||||||
assertTrue(toolResultResult.contains("echo"), "tool result содержит реальный ответ тулы: $toolResultResult")
|
|
||||||
|
|
||||||
// working memory: user + tool_exchange
|
|
||||||
val wm = sqliteStores.workingMemory.list(conv.id)
|
|
||||||
val exchanges = wm.mapNotNull { (it.entry as? WorkingMemoryEntry.ToolExchange) }
|
|
||||||
assertEquals(1, exchanges.size)
|
|
||||||
assertEquals("echo_tool", exchanges[0].toolName)
|
|
||||||
assertFalse(exchanges[0].wasCancelled, "tool реально выполнился, не был отменён")
|
|
||||||
assertTrue(exchanges[0].resultText.contains("echo"))
|
|
||||||
|
|
||||||
// events должны включать ToolCall + ToolResult. End — обязательно (turn завершился).
|
|
||||||
assertTrue(events.any { it is ProtoEvent.ToolCall }, "events=$events")
|
|
||||||
assertTrue(events.any { it is ProtoEvent.ToolResult }, "events=$events")
|
|
||||||
assertTrue(events.any { it is ProtoEvent.End }, "events=$events")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `temp conversation is not persisted across agent instances`() = runTest {
|
|
||||||
// Поднимаем file-backed БД, создаём temp-беседу
|
|
||||||
sqliteStores.close()
|
|
||||||
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
|
|
||||||
sqliteStores = KsqliteStores.open(dbPath)
|
|
||||||
val agent1 = ChatAgent(
|
|
||||||
id = "agentik",
|
|
||||||
conversationStore = sqliteStores.conversations,
|
|
||||||
messageStore = sqliteStores.messages,
|
|
||||||
workingMemoryStore = sqliteStores.workingMemory,
|
|
||||||
reflectionStore = sqliteStores.reflections,
|
|
||||||
llm = FakeLiteLlm().also { fakeLlm = it },
|
|
||||||
llmConfig = LlmConfig(
|
|
||||||
backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI,
|
|
||||||
systemPrompt = "be brief",
|
|
||||||
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val tempConv = agent1.createConversation(temp = true)
|
|
||||||
val tempId = tempConv.id
|
|
||||||
assertNotNull(agent1.getConversation(tempId))
|
|
||||||
|
|
||||||
// Переоткрываем БД — temp-беседа не должна пережить рестарт
|
|
||||||
sqliteStores.close()
|
|
||||||
sqliteStores = KsqliteStores.open(dbPath)
|
|
||||||
val agent2 = ChatAgent(
|
|
||||||
id = "agentik",
|
|
||||||
conversationStore = sqliteStores.conversations,
|
|
||||||
messageStore = sqliteStores.messages,
|
|
||||||
workingMemoryStore = sqliteStores.workingMemory,
|
|
||||||
reflectionStore = sqliteStores.reflections,
|
|
||||||
llm = fakeLlm,
|
|
||||||
llmConfig = LlmConfig(
|
|
||||||
backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI,
|
|
||||||
systemPrompt = "be brief",
|
|
||||||
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertNull(agent2.getConversation(tempId))
|
|
||||||
java.io.File(dbPath).delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
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)
|
|
||||||
val agent1 = ChatAgent(
|
|
||||||
id = "agentik",
|
|
||||||
conversationStore = sqliteStores.conversations,
|
|
||||||
messageStore = sqliteStores.messages,
|
|
||||||
workingMemoryStore = sqliteStores.workingMemory,
|
|
||||||
reflectionStore = sqliteStores.reflections,
|
|
||||||
llm = FakeLiteLlm().also { fakeLlm = it },
|
|
||||||
llmConfig = LlmConfig(
|
|
||||||
backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI,
|
|
||||||
systemPrompt = "be brief",
|
|
||||||
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val conv = agent1.createConversation(temp = false)
|
|
||||||
val id = conv.id
|
|
||||||
|
|
||||||
sqliteStores.close()
|
|
||||||
sqliteStores = KsqliteStores.open(dbPath)
|
|
||||||
val agent2 = ChatAgent(
|
|
||||||
id = "agentik",
|
|
||||||
conversationStore = sqliteStores.conversations,
|
|
||||||
messageStore = sqliteStores.messages,
|
|
||||||
workingMemoryStore = sqliteStores.workingMemory,
|
|
||||||
reflectionStore = sqliteStores.reflections,
|
|
||||||
llm = fakeLlm,
|
|
||||||
llmConfig = LlmConfig(
|
|
||||||
backend = pw.binom.agentik.standalone.llm.LlmBackend.OPENAI,
|
|
||||||
systemPrompt = "be brief",
|
|
||||||
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertNotNull(agent2.getConversation(id))
|
|
||||||
java.io.File(dbPath).delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fakeLiteLlmForReload(): LiteLlm = object : LiteLlm {
|
|
||||||
override val backendName: String = "fake"
|
|
||||||
override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null)
|
|
||||||
override fun isInitialized(): Boolean = true
|
|
||||||
override fun createConversation(config: LiteConversationConfig): LiteConversation =
|
|
||||||
error("not used in reload test")
|
|
||||||
override fun infer(request: pw.binom.litert.LiteRequest): String = error("not used")
|
|
||||||
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> = error("not used")
|
|
||||||
override fun close() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `tool-call loop executes registered tool and feeds result back`() = runTest {
|
|
||||||
val echoTool = object : LiteTool {
|
|
||||||
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 conv = agent.createConversation(temp = false)
|
|
||||||
|
|
||||||
conv.send(listOf(pw.binom.agentik.proto.Content.Text("call the tool")))
|
|
||||||
|
|
||||||
// sendStreamContents вызывается дважды: первый раз с user-сообщением
|
|
||||||
// (LLM отвечает tool_call), второй раз — после addToolResult — для
|
|
||||||
// триггера continuation у stateless-бэкендов (OpenAI). На этой fake
|
|
||||||
// LiteLlm оба попадают в счётчик.
|
|
||||||
assertEquals(2, toolLlm.toolCallCount, "expected user send + post-tool continuation")
|
|
||||||
assertEquals("echoed: {\"x\":\"hi\"}", toolLlm.lastToolResult,
|
|
||||||
"expected echo tool invoked with the LLM's args, result fed back via addToolResult")
|
|
||||||
assertEquals("final reply", toolLlm.finalReplyEmitted,
|
|
||||||
"expected continuation send after tool result to emit final text")
|
|
||||||
|
|
||||||
agent.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `agentEvents - Created + Deleted flow`() = runTest {
|
|
||||||
val agent = newAgent()
|
|
||||||
val events = mutableListOf<AgentEvent>()
|
|
||||||
val job = launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
|
||||||
// Agent.events() удалён из :proto — события живут в
|
|
||||||
// agent.outbox.agentEvents(): Flow<CommonEvent.Agent>;
|
|
||||||
// распаковываем .event для получения AgentEvent.
|
|
||||||
agent.outbox.agentEvents(Instant.DISTANT_PAST).collect { events.add(it.event) }
|
|
||||||
}
|
|
||||||
val conv = agent.createConversation(temp = false)
|
|
||||||
agent.deleteConversation(conv.id)
|
|
||||||
delay(50)
|
|
||||||
job.cancel()
|
|
||||||
|
|
||||||
assertEquals(2, events.size)
|
|
||||||
val created = events[0] as AgentEvent.Created
|
|
||||||
val deleted = events[1] as AgentEvent.Deleted
|
|
||||||
assertEquals(conv.id, created.conversationId)
|
|
||||||
assertEquals(conv.id, deleted.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Поддельный LiteLlm: возвращает fakeLlm.reply в sendStreamContents, опционально запоминает history. */
|
|
||||||
private class ToolLoopFakeLiteLlm : LiteLlm {
|
|
||||||
override val backendName: String = "fake-tool"
|
|
||||||
override val capabilities: pw.binom.litert.LiteCapabilities? = null
|
|
||||||
|
|
||||||
var toolCallCount: Int = 0
|
|
||||||
var lastToolResult: String? = null
|
|
||||||
var finalReplyEmitted: String? = null
|
|
||||||
|
|
||||||
override fun isInitialized(): Boolean = true
|
|
||||||
|
|
||||||
override fun createConversation(config: LiteConversationConfig): LiteConversation {
|
|
||||||
return object : LiteConversation {
|
|
||||||
private val hist = mutableListOf<LiteMessage>()
|
|
||||||
override val history: List<LiteMessage> get() = hist.toList()
|
|
||||||
override fun sendStream(prompt: String) = sendStreamContents(listOf(LiteContentPart.Text(prompt)))
|
|
||||||
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
|
|
||||||
hist.add(LiteMessage(LiteRole.USER, contents))
|
|
||||||
toolCallCount++
|
|
||||||
return flowOf(
|
|
||||||
LiteDelta(
|
|
||||||
text = "",
|
|
||||||
isDone = true,
|
|
||||||
toolCalls = listOf(LiteToolCall(name = "echo", arguments = mapOf("x" to "hi"))),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
override fun send(prompt: String): String = "unused"
|
|
||||||
override fun sendContents(contents: List<LiteContentPart>): String = "unused"
|
|
||||||
override fun cancel() {}
|
|
||||||
override fun tokenCount(): Int = hist.size
|
|
||||||
override fun addToolResult(callId: String?, name: String, result: String): LiteDelta {
|
|
||||||
lastToolResult = result
|
|
||||||
val reply = "final reply"
|
|
||||||
finalReplyEmitted = reply
|
|
||||||
return LiteDelta(text = reply, isDone = true)
|
|
||||||
}
|
|
||||||
override fun close() {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun infer(request: pw.binom.litert.LiteRequest): String = error("not used")
|
|
||||||
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> = error("not used")
|
|
||||||
override fun close() {}
|
|
||||||
}
|
|
||||||
-200
@@ -1,200 +0,0 @@
|
|||||||
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.toolsets.ToolsetContribution
|
|
||||||
import pw.binom.litert.LiteLlm
|
|
||||||
import pw.binom.litert.LiteTool
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertFalse
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Интеграционные тесты ChatAgent + toolsets: проверяем что при пустом
|
|
||||||
* toolsets=List (дефолт) агент ведёт себя как раньше (нет enable/disable тулов,
|
|
||||||
* нет секции в system prompt), а при non-empty — добавляет их и регистрирует
|
|
||||||
* диспетчер.
|
|
||||||
*/
|
|
||||||
class ChatAgentToolsetsTest {
|
|
||||||
|
|
||||||
private fun stubTool(name: String, response: String = "ok:$name"): LiteTool = object : LiteTool {
|
|
||||||
override fun describe() = """{"name":"$name","description":"stub","parameters":{"type":"object","properties":{}}}"""
|
|
||||||
override fun invoke(arguments: String) = response
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stubLlm(): LiteLlm = object : LiteLlm {
|
|
||||||
override val backendName: String = "stub"
|
|
||||||
override fun isInitialized(): Boolean = true
|
|
||||||
override fun createConversation(config: pw.binom.litert.LiteConversationConfig): pw.binom.litert.LiteConversation =
|
|
||||||
throw UnsupportedOperationException("not used in this test")
|
|
||||||
override fun infer(request: pw.binom.litert.LiteRequest): String =
|
|
||||||
throw UnsupportedOperationException("not used in this test")
|
|
||||||
override fun inferStream(request: pw.binom.litert.LiteRequest): kotlinx.coroutines.flow.Flow<pw.binom.litert.LiteDelta> =
|
|
||||||
throw UnsupportedOperationException("not used in this test")
|
|
||||||
override val capabilities: pw.binom.litert.LiteCapabilities? = null
|
|
||||||
override fun close() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun newAgent(
|
|
||||||
toolsets: List<ToolsetContribution> = emptyList(),
|
|
||||||
): Pair<ChatAgent, pw.binom.agentik.storage.ksqlite.KsqliteStores> {
|
|
||||||
val sqliteStores = KsqliteStores.inMemory("toolsets-${kotlin.random.Random.nextLong()}")
|
|
||||||
val agent = ChatAgent(
|
|
||||||
id = "test-agent",
|
|
||||||
conversationStore = sqliteStores.conversations,
|
|
||||||
messageStore = sqliteStores.messages,
|
|
||||||
workingMemoryStore = sqliteStores.workingMemory,
|
|
||||||
reflectionStore = sqliteStores.reflections,
|
|
||||||
llm = stubLlm(),
|
|
||||||
llmConfig = LlmConfig(
|
|
||||||
backend = pw.binom.agentik.standalone.llm.LlmBackend.GOOGLE,
|
|
||||||
systemPrompt = "base",
|
|
||||||
google = pw.binom.agentik.standalone.llm.GoogleConfig(modelPath = "/tmp/fake.gguf"),
|
|
||||||
),
|
|
||||||
toolsets = toolsets,
|
|
||||||
)
|
|
||||||
return agent to sqliteStores
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `default (empty toolsets) does not register enable or disable tools`() {
|
|
||||||
val (agent, storage) = newAgent()
|
|
||||||
try {
|
|
||||||
// Проверяем через allTools-эквивалент: вызываем enable_toolset
|
|
||||||
// и ожидаем что он не найден — это значит тул не зарегистрирован.
|
|
||||||
val conv = agent.createConversation(temp = true)
|
|
||||||
// system prompt не должен содержать секции Toolsets
|
|
||||||
val prompt = (conv as ChatConversation).let { it.systemPromptForTest() }
|
|
||||||
assertFalse(prompt.contains("## Toolsets"), "toolsets section must NOT appear when toolsets empty")
|
|
||||||
assertFalse(prompt.contains("enable_toolset"), "enable_toolset must NOT be mentioned when toolsets empty")
|
|
||||||
} finally { storage.close() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `non-empty toolsets registers enable and disable tools and includes section`() {
|
|
||||||
val toolsets = listOf(
|
|
||||||
ToolsetContribution(
|
|
||||||
name = "media",
|
|
||||||
description = "image and video processing",
|
|
||||||
tools = listOf(ToolsetContribution.ToolEntry("resize_image", stubTool("resize_image"))),
|
|
||||||
),
|
|
||||||
ToolsetContribution(
|
|
||||||
name = "web",
|
|
||||||
description = "fetch and parse web pages",
|
|
||||||
tools = listOf(ToolsetContribution.ToolEntry("fetch_url", stubTool("fetch_url"))),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val (agent, storage) = newAgent(toolsets = toolsets)
|
|
||||||
try {
|
|
||||||
val conv = agent.createConversation(temp = true) as ChatConversation
|
|
||||||
val prompt = conv.systemPromptForTest()
|
|
||||||
assertTrue(prompt.contains("## Toolsets"), "toolsets section MUST appear when toolsets non-empty")
|
|
||||||
assertTrue(prompt.contains("- media — image and video processing"))
|
|
||||||
assertTrue(prompt.contains("- web — fetch and parse web pages"))
|
|
||||||
// Оба тула — в списке allTools (через system prompt проверяем только prompt;
|
|
||||||
// наличие тулов проверим отдельно — см. ниже).
|
|
||||||
} finally { storage.close() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `non-empty toolsets - enable_toolset activates and dispatches auto-included tools`() {
|
|
||||||
val mediaTool = stubTool("resize_image", "image-resized-100x100")
|
|
||||||
val toolsets = listOf(
|
|
||||||
ToolsetContribution(
|
|
||||||
name = "media",
|
|
||||||
description = "image and video processing",
|
|
||||||
tools = listOf(ToolsetContribution.ToolEntry("resize_image", mediaTool)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val (agent, storage) = newAgent(toolsets = toolsets)
|
|
||||||
try {
|
|
||||||
val conv = agent.createConversation(temp = true) as ChatConversation
|
|
||||||
|
|
||||||
// Вызываем enable_toolset через диспетчер (минуя LLM, напрямую)
|
|
||||||
val enabled = runBlocking {
|
|
||||||
conv.dispatchToolForTest("enable_toolset", """{"name":"media"}""")
|
|
||||||
}
|
|
||||||
assertEquals("Toolset 'media' activated.", enabled)
|
|
||||||
|
|
||||||
// Теперь resize_image должен работать (тулсет активен)
|
|
||||||
val resized = runBlocking {
|
|
||||||
conv.dispatchToolForTest("resize_image", "{}")
|
|
||||||
}
|
|
||||||
assertEquals("image-resized-100x100", resized)
|
|
||||||
} finally { storage.close() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `non-empty toolsets - calling tool from inactive toolset triggers auto-activation`() {
|
|
||||||
val mediaTool = stubTool("resize_image", "auto-activated-and-ran")
|
|
||||||
val toolsets = listOf(
|
|
||||||
ToolsetContribution(
|
|
||||||
name = "media",
|
|
||||||
description = "x",
|
|
||||||
tools = listOf(ToolsetContribution.ToolEntry("resize_image", mediaTool)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val (agent, storage) = newAgent(toolsets = toolsets)
|
|
||||||
try {
|
|
||||||
val conv = agent.createConversation(temp = true) as ChatConversation
|
|
||||||
|
|
||||||
// Без enable сразу вызываем resize_image — диспетчер должен auto-activate.
|
|
||||||
val result = runBlocking {
|
|
||||||
conv.dispatchToolForTest("resize_image", "{}")
|
|
||||||
}
|
|
||||||
assertEquals("auto-activated-and-ran", result)
|
|
||||||
} finally { storage.close() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `non-empty toolsets - disable_toolset removes from active list`() {
|
|
||||||
// Тест проверяет только сайд-эффект на реестре (active set),
|
|
||||||
// а не реальный dispatch — потому что диспетчер auto-activate'ит тулсет
|
|
||||||
// обратно при следующем вызове (это by design: если модель забыла что
|
|
||||||
// тулсет выключен, мы прощаем и включаем заново).
|
|
||||||
val toolsets = listOf(
|
|
||||||
ToolsetContribution(
|
|
||||||
name = "media",
|
|
||||||
description = "x",
|
|
||||||
tools = listOf(ToolsetContribution.ToolEntry("resize_image", stubTool("resize_image"))),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val (agent, storage) = newAgent(toolsets = toolsets)
|
|
||||||
try {
|
|
||||||
val conv = agent.createConversation(temp = true) as ChatConversation
|
|
||||||
|
|
||||||
runBlocking { conv.dispatchToolForTest("enable_toolset", """{"name":"media"}""") }
|
|
||||||
// disable
|
|
||||||
val disabled = runBlocking {
|
|
||||||
conv.dispatchToolForTest("disable_toolset", """{"name":"media"}""")
|
|
||||||
}
|
|
||||||
assertEquals("Toolset 'media' deactivated.", disabled)
|
|
||||||
// После disable реестр уже не содержит media в active
|
|
||||||
// (проверяем косвенно: disable повторно всё ещё возвращает тот же uniform message)
|
|
||||||
val disabledAgain = runBlocking {
|
|
||||||
conv.dispatchToolForTest("disable_toolset", """{"name":"media"}""")
|
|
||||||
}
|
|
||||||
assertEquals("Toolset 'media' deactivated.", disabledAgain)
|
|
||||||
} finally { storage.close() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вспомогательные 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
|
|
||||||
}
|
|
||||||
|
|
||||||
internal suspend fun ChatConversation.dispatchToolForTest(toolName: String, argsJson: String): String {
|
|
||||||
// Через toolsetDispatch (если есть) или прямой toolsByName.
|
|
||||||
val dispatchField = this::class.java.declaredFields.first { it.name == "toolsetDispatch" }.apply { isAccessible = true }
|
|
||||||
val dispatch = dispatchField.get(this) ?: error("toolsetDispatch must be set when toolsets present")
|
|
||||||
val outcome = (dispatch as pw.binom.agentik.toolsets.ToolsetDispatchPolicy).dispatch(toolName, argsJson)
|
|
||||||
return when (outcome) {
|
|
||||||
is pw.binom.agentik.toolsets.ToolsetDispatchPolicy.Outcome.Ran -> outcome.result
|
|
||||||
is pw.binom.agentik.toolsets.ToolsetDispatchPolicy.Outcome.Unknown -> "[tool not found: $toolName]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import pw.binom.agentik.memory.MemoryCategory
|
|
||||||
import pw.binom.agentik.memory.MemoryNote
|
|
||||||
import pw.binom.agentik.memory.MemorySource
|
|
||||||
import pw.binom.agentik.memory.MemoryStore
|
|
||||||
import pw.binom.agentik.memory.NewMemoryNote
|
|
||||||
import pw.binom.agentik.memory.ReviewedTurn
|
|
||||||
import pw.binom.agentik.memory.md.KeywordMdReviewer
|
|
||||||
import pw.binom.agentik.standalone.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.proto.Content as ProtoContent
|
|
||||||
import pw.binom.litert.LiteConversation
|
|
||||||
import pw.binom.litert.LiteConversationConfig
|
|
||||||
import pw.binom.litert.LiteLlm
|
|
||||||
import pw.binom.litert.LiteMessage
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import pw.binom.agentik.memory.MemoryStoreEvent
|
|
||||||
import kotlin.test.AfterTest
|
|
||||||
import kotlin.test.BeforeTest
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertIs
|
|
||||||
import kotlin.test.assertNotNull
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
import kotlin.test.assertFalse
|
|
||||||
import kotlin.time.Instant
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import pw.binom.agentik.llm.tools.ContextCompactor
|
|
||||||
import pw.binom.agentik.llm.tools.SummaryTurn
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Тесты для [ChatConversation.compactPreTurnIfNeeded]: триггер compaction'а
|
|
||||||
* при превышении порога, вызов суммаризатора, триггер memory review, и
|
|
||||||
* атомарный replace в working memory.
|
|
||||||
*/
|
|
||||||
class CompactionTest {
|
|
||||||
|
|
||||||
private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores
|
|
||||||
private lateinit var fakeLlm: FakeLiteLlm
|
|
||||||
|
|
||||||
@BeforeTest
|
|
||||||
fun setup() {
|
|
||||||
sqliteStores = KsqliteStores.inMemory("compact-${kotlin.random.Random.nextLong()}")
|
|
||||||
fakeLlm = FakeLiteLlm()
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterTest
|
|
||||||
fun tearDown() {
|
|
||||||
sqliteStores.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun newAgent(
|
|
||||||
contextWindow: Int? = null,
|
|
||||||
compressionThreshold: Double = 0.8,
|
|
||||||
compactor: ContextCompactor? = null,
|
|
||||||
memoryStore: MemoryStore? = null,
|
|
||||||
): ChatAgent {
|
|
||||||
val reviewer = if (memoryStore != null) KeywordMdReviewer() else null
|
|
||||||
return ChatAgent(
|
|
||||||
id = "test",
|
|
||||||
conversationStore = sqliteStores.conversations,
|
|
||||||
messageStore = sqliteStores.messages,
|
|
||||||
workingMemoryStore = sqliteStores.workingMemory,
|
|
||||||
reflectionStore = sqliteStores.reflections,
|
|
||||||
llm = fakeLlm,
|
|
||||||
llmConfig = LlmConfig(
|
|
||||||
backend = LlmBackend.OPENAI,
|
|
||||||
systemPrompt = "be brief",
|
|
||||||
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
|
||||||
),
|
|
||||||
memoryStore = memoryStore,
|
|
||||||
memoryReviewer = reviewer,
|
|
||||||
contextWindow = contextWindow,
|
|
||||||
compressionThreshold = compressionThreshold,
|
|
||||||
contextCompactor = compactor,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `compaction is no-op when contextWindow is null`() = runTest {
|
|
||||||
// contextWindow=null → даже с огромной историей compaction не запустится.
|
|
||||||
fakeLlm.reply = "hi"
|
|
||||||
val agent = newAgent(contextWindow = null, compactor = RecordingCompactor("summary"))
|
|
||||||
val conv = agent.createConversation(temp = false) as ChatConversation
|
|
||||||
repeat(10) {
|
|
||||||
conv.send(listOf(ProtoContent.Text("turn $it: ${"x".repeat(200)}")))
|
|
||||||
}
|
|
||||||
val wm = sqliteStores.workingMemory.list(conv.id)
|
|
||||||
// Без compaction все ходы остаются в памяти (System + 10 user/assistant = 21 строк).
|
|
||||||
val summaries = wm.filter { it.entry is pw.binom.agentik.context.WorkingMemoryEntry.Summary }
|
|
||||||
assertEquals(0, summaries.size, "compaction must not run without contextWindow")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `compaction is no-op when compactor is null but window is set`() = runTest {
|
|
||||||
fakeLlm.reply = "hi"
|
|
||||||
val agent = newAgent(contextWindow = 10, compactor = null)
|
|
||||||
val conv = agent.createConversation(temp = false) as ChatConversation
|
|
||||||
conv.send(listOf(ProtoContent.Text("first")))
|
|
||||||
val wm = sqliteStores.workingMemory.list(conv.id)
|
|
||||||
// System + User + Assistant = 3. Без compactor — никаких Summary.
|
|
||||||
val summaries = wm.filter { it.entry is pw.binom.agentik.context.WorkingMemoryEntry.Summary }
|
|
||||||
assertEquals(0, summaries.size, "no compaction runs without compactor")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `compaction triggers when estimated tokens exceed threshold`() = runTest {
|
|
||||||
fakeLlm.reply = "ok"
|
|
||||||
val compactor = RecordingCompactor("**Goal**: x\n**Active**: y\n**Resolved**: z")
|
|
||||||
// contextWindow = 20 chars → ~5 токенов. С порогом 0.5 (50%) — почти любой ход пробивает.
|
|
||||||
val agent = newAgent(contextWindow = 20, compressionThreshold = 0.5, compactor = compactor)
|
|
||||||
val conv = agent.createConversation(temp = false) as ChatConversation
|
|
||||||
|
|
||||||
conv.send(listOf(ProtoContent.Text("user message one — long enough to cross threshold")))
|
|
||||||
|
|
||||||
// Compactor должен был быть вызван хотя бы раз.
|
|
||||||
assertTrue(compactor.calls > 0, "compactor must be called at least once when above threshold")
|
|
||||||
// В working memory должна появиться Summary.
|
|
||||||
val wm = sqliteStores.workingMemory.list(conv.id)
|
|
||||||
val summaries = wm.filter { it.entry is pw.binom.agentik.context.WorkingMemoryEntry.Summary }
|
|
||||||
assertTrue(summaries.isNotEmpty(), "at least one Summary entry should be present after compaction")
|
|
||||||
// Summary-текст — то, что вернул наш compactor.
|
|
||||||
val summaryText = (summaries.first().entry as pw.binom.agentik.context.WorkingMemoryEntry.Summary).text
|
|
||||||
assertTrue(summaryText.startsWith("**Goal**"), "summary text should come from compactor: $summaryText")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `compaction calls memoryReviewer reviewPreCompaction`() = runTest {
|
|
||||||
fakeLlm.reply = "ok"
|
|
||||||
val memStore = TestInMemoryMemoryStore()
|
|
||||||
val compactor = RecordingCompactor("compacted summary")
|
|
||||||
val agent = newAgent(
|
|
||||||
contextWindow = 30,
|
|
||||||
compressionThreshold = 0.5,
|
|
||||||
compactor = compactor,
|
|
||||||
memoryStore = memStore,
|
|
||||||
)
|
|
||||||
val conv = agent.createConversation(temp = false) as ChatConversation
|
|
||||||
|
|
||||||
conv.send(listOf(ProtoContent.Text("Я обычно предпочитаю kotlin для бэкенда.")))
|
|
||||||
|
|
||||||
// Память должна получить хотя бы одну заметку от reviewPreCompaction.
|
|
||||||
val notes = memStore.list()
|
|
||||||
assertTrue(notes.any { it.category == MemoryCategory.PREFERENCE && it.content.contains("kotlin") },
|
|
||||||
"memory should capture a preference fact before compaction drops the turn")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `compaction preserves recent turns (KEEP_RECENT_TURNS)`() = runTest {
|
|
||||||
fakeLlm.reply = "ok"
|
|
||||||
val compactor = RecordingCompactor("compacted summary")
|
|
||||||
val agent = newAgent(contextWindow = 30, compressionThreshold = 0.3, compactor = compactor)
|
|
||||||
val conv = agent.createConversation(temp = false) as ChatConversation
|
|
||||||
|
|
||||||
conv.send(listOf(ProtoContent.Text("first turn")))
|
|
||||||
conv.send(listOf(ProtoContent.Text("second turn")))
|
|
||||||
conv.send(listOf(ProtoContent.Text("third turn — long content ${"y".repeat(150)}")))
|
|
||||||
|
|
||||||
val wm = sqliteStores.workingMemory.list(conv.id)
|
|
||||||
// Должны быть: System + хотя бы один Summary + последние KEEP_RECENT_TURNS ходов.
|
|
||||||
// KEEP_RECENT_TURNS = 4 → user/assistant последних двух ходов (third + second) могут быть не тронуты.
|
|
||||||
val userAssistantCount = wm.count {
|
|
||||||
it.entry is pw.binom.agentik.context.WorkingMemoryEntry.User ||
|
|
||||||
it.entry is pw.binom.agentik.context.WorkingMemoryEntry.Assistant
|
|
||||||
}
|
|
||||||
// Минимум 1 ход остаётся (KEEP_RECENT_TURNS).
|
|
||||||
assertTrue(userAssistantCount >= 1, "at least one recent turn must be preserved")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class RecordingCompactor(private val result: String) : ContextCompactor {
|
|
||||||
var calls = 0
|
|
||||||
override suspend fun summarize(turns: List<SummaryTurn>): String {
|
|
||||||
calls++
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* (InMemoryMemoryStore вынесен в [TestInMemoryMemoryStore].)
|
|
||||||
*/
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
|
||||||
|
|
||||||
import pw.binom.agentik.journal.MessageContext
|
|
||||||
import pw.binom.agentik.journal.MessageOrigin.EVENT
|
|
||||||
import pw.binom.agentik.journal.MessageOrigin.SYSTEM
|
|
||||||
import pw.binom.agentik.journal.MessageOrigin.USER
|
|
||||||
import pw.binom.litert.LiteContentPart
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertIs
|
|
||||||
import kotlin.test.assertNull
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Тесты для префикса контекста инициации хода в user-сообщениях.
|
|
||||||
* Только для не-USER origin'ов. USER — без изменений.
|
|
||||||
*/
|
|
||||||
class ContextPrefixTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `USER origin produces no prefix`() {
|
|
||||||
val ctx = MessageContext(origin = USER)
|
|
||||||
val parts = listOf(LiteContentPart.Text("hello"))
|
|
||||||
val out = applyContextPrefix(parts, ctx)
|
|
||||||
assertEquals(parts, out, "USER should not modify content")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `null context produces no prefix`() {
|
|
||||||
val parts = listOf(LiteContentPart.Text("hello"))
|
|
||||||
val out = applyContextPrefix(parts, null)
|
|
||||||
assertEquals(parts, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `SYSTEM origin prepends label to first text part`() {
|
|
||||||
val ctx = MessageContext(origin = SYSTEM, description = "agent startup greeting")
|
|
||||||
val parts = listOf(LiteContentPart.Text("boot"))
|
|
||||||
val out = applyContextPrefix(parts, ctx)
|
|
||||||
assertEquals(1, out.size)
|
|
||||||
val text = assertIs<LiteContentPart.Text>(out[0])
|
|
||||||
assertEquals("[SYSTEM] agent startup greeting\nboot", text.text)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `EVENT origin with sourceId includes it`() {
|
|
||||||
val ctx = MessageContext(
|
|
||||||
origin = EVENT,
|
|
||||||
description = "scheduled cron morning-briefing",
|
|
||||||
sourceId = "cron-42",
|
|
||||||
)
|
|
||||||
val parts = listOf(LiteContentPart.Text("wake up"))
|
|
||||||
val out = applyContextPrefix(parts, ctx)
|
|
||||||
val text = assertIs<LiteContentPart.Text>(out[0])
|
|
||||||
assertEquals("[EVENT] scheduled cron morning-briefing (sourceId=cron-42)\nwake up", text.text)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `prefix only added to first text part, others untouched`() {
|
|
||||||
val ctx = MessageContext(origin = EVENT, description = "test")
|
|
||||||
val parts = listOf(
|
|
||||||
LiteContentPart.Text("first"),
|
|
||||||
LiteContentPart.Text("second"),
|
|
||||||
)
|
|
||||||
val out = applyContextPrefix(parts, ctx)
|
|
||||||
assertEquals(2, out.size)
|
|
||||||
val first = assertIs<LiteContentPart.Text>(out[0])
|
|
||||||
val second = assertIs<LiteContentPart.Text>(out[1])
|
|
||||||
assertEquals("[EVENT] test\nfirst", first.text)
|
|
||||||
assertEquals("second", second.text)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `prefix with no text parts is prepended as standalone text`() {
|
|
||||||
val ctx = MessageContext(origin = SYSTEM, description = "ping")
|
|
||||||
// Симулируем: модель получает картинку + контекст — контекст идёт первой Text-частью.
|
|
||||||
val parts = listOf<LiteContentPart>(LiteContentPart.Text("just prefix"))
|
|
||||||
val out = applyContextPrefix(parts, ctx)
|
|
||||||
assertEquals(1, out.size)
|
|
||||||
val text = assertIs<LiteContentPart.Text>(out[0])
|
|
||||||
assertEquals("[SYSTEM] ping\njust prefix", text.text)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `formatContextPrefix formats name + description + sourceId`() {
|
|
||||||
val ctx = MessageContext(origin = EVENT, description = "wake", sourceId = "cron-1")
|
|
||||||
assertEquals("[EVENT] wake (sourceId=cron-1)", formatContextPrefix(ctx))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `formatContextPrefix omits blank description and sourceId`() {
|
|
||||||
val ctx = MessageContext(origin = SYSTEM)
|
|
||||||
assertEquals("[SYSTEM]", formatContextPrefix(ctx))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `formatContextPrefix omits blank sourceId even if description is set`() {
|
|
||||||
val ctx = MessageContext(origin = SYSTEM, description = "boot", sourceId = "")
|
|
||||||
assertEquals("[SYSTEM] boot", formatContextPrefix(ctx))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `USER origin with context fields still produces no prefix`() {
|
|
||||||
// Контекст с USER-происхождением, но с заполненным description/sourceId:
|
|
||||||
// не должен триггерить префикс (UI-метаданные для логирования).
|
|
||||||
val ctx = MessageContext(origin = USER, sourceId = "irc:agentik", description = "PRIVMSG")
|
|
||||||
val parts = listOf(LiteContentPart.Text("hi"))
|
|
||||||
val out = applyContextPrefix(parts, ctx)
|
|
||||||
assertEquals(parts, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вспомогательное для теста
|
|
||||||
@Test
|
|
||||||
fun `null-context assert helper`() {
|
|
||||||
assertNull(null as String?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import pw.binom.litert.LiteContentPart
|
|
||||||
import pw.binom.litert.LiteConversation
|
|
||||||
import pw.binom.litert.LiteConversationConfig
|
|
||||||
import pw.binom.litert.LiteDelta
|
|
||||||
import pw.binom.litert.LiteLlm
|
|
||||||
import pw.binom.litert.LiteMessage
|
|
||||||
import pw.binom.litert.LiteRole
|
|
||||||
import pw.binom.litert.LiteToolCall
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Тестовая [LiteLlm], запоминающая последний конфиг/контент и отвечающая
|
|
||||||
* заданной строкой [reply] двумя фрагментами + done.
|
|
||||||
*/
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
override val backendName: String = "fake"
|
|
||||||
override val capabilities: pw.binom.litert.LiteCapabilities = pw.binom.litert.LiteCapabilities(pw.binom.litert.LiteInputModalities.TextOnly, false, false, null)
|
|
||||||
var reply: String = ""
|
|
||||||
var rememberHistory: Boolean = false
|
|
||||||
var slow: Boolean = false
|
|
||||||
var failMessage: String? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Если задан, LLM проходит по этому списку ответов по порядку: первый
|
|
||||||
* sendStreamContents → первый Reply, второй → второй и т.д. Если список
|
|
||||||
* кончился — fallback на [reply] (text).
|
|
||||||
*/
|
|
||||||
var scriptedReplies: MutableList<Reply> = mutableListOf()
|
|
||||||
|
|
||||||
var lastConfig: LiteConversationConfig? = null
|
|
||||||
var lastContents: List<LiteContentPart>? = null
|
|
||||||
val conversations = mutableListOf<FakeLiteConversation>()
|
|
||||||
|
|
||||||
override fun isInitialized(): Boolean = true
|
|
||||||
|
|
||||||
override fun createConversation(config: LiteConversationConfig): LiteConversation {
|
|
||||||
lastConfig = config
|
|
||||||
val conv = FakeLiteConversation(this, config)
|
|
||||||
conversations.add(conv)
|
|
||||||
return conv
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun infer(request: pw.binom.litert.LiteRequest): String =
|
|
||||||
throw UnsupportedOperationException("not used in test")
|
|
||||||
|
|
||||||
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> =
|
|
||||||
throw UnsupportedOperationException("not used in test")
|
|
||||||
|
|
||||||
override fun close() {}
|
|
||||||
|
|
||||||
fun nextReply(): Reply =
|
|
||||||
if (scriptedReplies.isNotEmpty()) scriptedReplies.removeAt(0) else Reply.Text(reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class FakeLiteConversation(
|
|
||||||
private val parent: FakeLiteLlm,
|
|
||||||
config: LiteConversationConfig,
|
|
||||||
) : LiteConversation {
|
|
||||||
val initialMessages: List<LiteMessage> = config.initialMessages
|
|
||||||
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
|
|
||||||
override val history: List<LiteMessage> get() = mutableHistory.toList()
|
|
||||||
|
|
||||||
override fun sendStream(prompt: String): Flow<LiteDelta> =
|
|
||||||
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
|
|
||||||
|
|
||||||
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
|
|
||||||
parent.lastContents = contents
|
|
||||||
parent.failMessage?.let { msg ->
|
|
||||||
return kotlinx.coroutines.flow.flow { throw RuntimeException(msg) }
|
|
||||||
}
|
|
||||||
mutableHistory.add(LiteMessage(LiteRole.USER, contents))
|
|
||||||
val next = parent.nextReply()
|
|
||||||
return when (next) {
|
|
||||||
is FakeLiteLlm.Reply.Text -> {
|
|
||||||
if (parent.slow) {
|
|
||||||
kotlinx.coroutines.flow.flow {
|
|
||||||
emit(LiteDelta(text = next.text.substring(0, next.text.length / 2)))
|
|
||||||
kotlinx.coroutines.delay(10_000)
|
|
||||||
emit(LiteDelta(text = next.text.substring(next.text.length / 2), isDone = true))
|
|
||||||
mutableHistory.add(LiteMessage.model(next.text))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val first = next.text.substring(0, next.text.length / 2)
|
|
||||||
val second = next.text.substring(next.text.length / 2)
|
|
||||||
flowOf(
|
|
||||||
LiteDelta(text = first),
|
|
||||||
LiteDelta(text = second, isDone = true),
|
|
||||||
).also { mutableHistory.add(LiteMessage.model(next.text)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is FakeLiteLlm.Reply.ToolCalls -> {
|
|
||||||
val calls = next.calls.map { (name, args) ->
|
|
||||||
LiteToolCall(name = name, arguments = args)
|
|
||||||
}
|
|
||||||
flowOf(LiteDelta(text = "", toolCalls = calls, isDone = true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
override fun send(prompt: String): String {
|
|
||||||
parent.lastContents = listOf(LiteContentPart.Text(prompt))
|
|
||||||
return parent.reply
|
|
||||||
}
|
|
||||||
override fun sendContents(contents: List<LiteContentPart>): String {
|
|
||||||
parent.lastContents = contents
|
|
||||||
return parent.reply
|
|
||||||
}
|
|
||||||
override fun cancel() {}
|
|
||||||
override fun tokenCount(): Int = history.size
|
|
||||||
override fun addToolResult(callId: String?, name: String, result: String): LiteDelta =
|
|
||||||
LiteDelta(text = "", isDone = true)
|
|
||||||
override fun close() {}
|
|
||||||
}
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
|
||||||
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.coroutines.withTimeout
|
|
||||||
import kotlinx.io.files.Path
|
|
||||||
import kotlinx.io.files.SystemFileSystem
|
|
||||||
import kotlinx.io.files.SystemTemporaryDirectory
|
|
||||||
import pw.binom.agentik.memory.ConversationTurn
|
|
||||||
import pw.binom.agentik.memory.MemoryCategory
|
|
||||||
import pw.binom.agentik.memory.MemoryNote
|
|
||||||
import pw.binom.agentik.memory.MemoryPrefetcher
|
|
||||||
import pw.binom.agentik.memory.MemoryReviewDecision
|
|
||||||
import pw.binom.agentik.memory.MemoryReviewer
|
|
||||||
import pw.binom.agentik.memory.MemorySearchQuery
|
|
||||||
import pw.binom.agentik.memory.MemorySearchResult
|
|
||||||
import pw.binom.agentik.memory.MemoryStore
|
|
||||||
import pw.binom.agentik.memory.MemorySource
|
|
||||||
import pw.binom.agentik.memory.MemorySystemGuidance
|
|
||||||
import pw.binom.agentik.memory.NewMemoryNote
|
|
||||||
import pw.binom.agentik.memory.ReviewedTurn
|
|
||||||
import pw.binom.agentik.memory.md.openMdMemorySystem
|
|
||||||
import pw.binom.agentik.proto.Content
|
|
||||||
import pw.binom.agentik.standalone.agent.memory.MemoryToolsFactory
|
|
||||||
import pw.binom.agentik.standalone.llm.LlmBackend
|
|
||||||
import pw.binom.agentik.standalone.llm.LlmConfig
|
|
||||||
import pw.binom.agentik.standalone.llm.OpenAiConfig
|
|
||||||
import pw.binom.agentik.storage.ksqlite.KsqliteStores
|
|
||||||
import pw.binom.agentik.llm.tools.ContextCompactor
|
|
||||||
import pw.binom.agentik.llm.tools.SummaryTurn
|
|
||||||
import kotlin.test.AfterTest
|
|
||||||
import kotlin.test.BeforeTest
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertNotNull
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Интеграция памяти в :standalone:
|
|
||||||
* - тулы memory_save/read/list/delete регистрируются у агента
|
|
||||||
* - prefetcher вставляет контекст в первое user-сообщение
|
|
||||||
* - reviewer пишет факты в store после хода
|
|
||||||
*/
|
|
||||||
class MemoryWiringTest {
|
|
||||||
|
|
||||||
private lateinit var sqliteStores: pw.binom.agentik.storage.ksqlite.KsqliteStores
|
|
||||||
private lateinit var fakeLlm: FakeLiteLlm
|
|
||||||
private lateinit var root: Path
|
|
||||||
|
|
||||||
@BeforeTest
|
|
||||||
fun setup() {
|
|
||||||
sqliteStores = KsqliteStores.inMemory("memwire-${kotlin.random.Random.nextLong()}")
|
|
||||||
fakeLlm = FakeLiteLlm()
|
|
||||||
root = Path(SystemTemporaryDirectory.toString(), "agentik-mem-${java.util.UUID.randomUUID()}")
|
|
||||||
SystemFileSystem.createDirectories(root, mustCreate = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterTest
|
|
||||||
fun tearDown() {
|
|
||||||
sqliteStores.close()
|
|
||||||
runCatching { SystemFileSystem.delete(root, mustExist = false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun newAgent(
|
|
||||||
memoryStore: MemoryStore,
|
|
||||||
prefetcher: MemoryPrefetcher,
|
|
||||||
reviewer: MemoryReviewer,
|
|
||||||
contextWindow: Int? = null,
|
|
||||||
compressionThreshold: Double = 0.8,
|
|
||||||
contextCompactor: ContextCompactor = EchoCompactor,
|
|
||||||
): ChatAgent = ChatAgent(
|
|
||||||
id = "agentik",
|
|
||||||
conversationStore = sqliteStores.conversations,
|
|
||||||
messageStore = sqliteStores.messages,
|
|
||||||
workingMemoryStore = sqliteStores.workingMemory,
|
|
||||||
reflectionStore = sqliteStores.reflections,
|
|
||||||
llm = fakeLlm,
|
|
||||||
llmConfig = LlmConfig(
|
|
||||||
backend = LlmBackend.OPENAI,
|
|
||||||
systemPrompt = "be brief",
|
|
||||||
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
|
||||||
),
|
|
||||||
memoryStore = memoryStore,
|
|
||||||
memoryPrefetcher = prefetcher,
|
|
||||||
memoryReviewer = reviewer,
|
|
||||||
contextWindow = contextWindow,
|
|
||||||
compressionThreshold = compressionThreshold,
|
|
||||||
contextCompactor = contextCompactor,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `system prompt includes memory guidance when memory is enabled`() = runBlocking {
|
|
||||||
val system = openMdMemorySystem(root)
|
|
||||||
val agent = newAgent(system.store, system.prefetcher, system.reviewer)
|
|
||||||
val conv = agent.createConversation(temp = false)
|
|
||||||
fakeLlm.reply = "ok"
|
|
||||||
conv.send(listOf(Content.Text("hi")))
|
|
||||||
// System prompt не пишется в working_memory — читаем то, что увидит LLM
|
|
||||||
val text = fakeLlm.lastConfig?.systemInstruction
|
|
||||||
assertNotNull(text)
|
|
||||||
assertTrue(text!!.contains(MemorySystemGuidance.MEMORY_GUIDANCE.take(80)),
|
|
||||||
"system prompt should contain MEMORY_GUIDANCE; got first 200 chars: ${text.take(200)}")
|
|
||||||
agent.close()
|
|
||||||
system.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `soul body is prepended to system prompt and wins over base`() = runBlocking {
|
|
||||||
val soulBody = "I am a helpful test persona. I always answer in one short line."
|
|
||||||
val agent = ChatAgent(
|
|
||||||
id = "agentik",
|
|
||||||
conversationStore = sqliteStores.conversations,
|
|
||||||
messageStore = sqliteStores.messages,
|
|
||||||
workingMemoryStore = sqliteStores.workingMemory,
|
|
||||||
reflectionStore = sqliteStores.reflections,
|
|
||||||
llm = fakeLlm,
|
|
||||||
llmConfig = LlmConfig(
|
|
||||||
backend = LlmBackend.OPENAI,
|
|
||||||
systemPrompt = "be brief",
|
|
||||||
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
|
||||||
),
|
|
||||||
soulBody = soulBody,
|
|
||||||
)
|
|
||||||
val conv = agent.createConversation(temp = false)
|
|
||||||
fakeLlm.reply = "ok"
|
|
||||||
conv.send(listOf(Content.Text("hi")))
|
|
||||||
val text = fakeLlm.lastConfig?.systemInstruction
|
|
||||||
assertNotNull(text)
|
|
||||||
assertTrue(text!!.startsWith(soulBody),
|
|
||||||
"soul should be the very first section; got first 60 chars: ${text.take(60)}")
|
|
||||||
assertTrue(text.contains("be brief"),
|
|
||||||
"base prompt should still follow the soul; got: $text")
|
|
||||||
agent.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `soul body not added when null`() = runBlocking {
|
|
||||||
val agent = ChatAgent(
|
|
||||||
id = "agentik",
|
|
||||||
conversationStore = sqliteStores.conversations,
|
|
||||||
messageStore = sqliteStores.messages,
|
|
||||||
workingMemoryStore = sqliteStores.workingMemory,
|
|
||||||
reflectionStore = sqliteStores.reflections,
|
|
||||||
llm = fakeLlm,
|
|
||||||
llmConfig = LlmConfig(
|
|
||||||
backend = LlmBackend.OPENAI,
|
|
||||||
systemPrompt = "be brief",
|
|
||||||
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val conv = agent.createConversation(temp = false)
|
|
||||||
fakeLlm.reply = "ok"
|
|
||||||
conv.send(listOf(Content.Text("hi")))
|
|
||||||
val text = fakeLlm.lastConfig?.systemInstruction
|
|
||||||
assertNotNull(text)
|
|
||||||
assertTrue(text!!.startsWith("be brief"),
|
|
||||||
"without soul, prompt should start with base; got first 60 chars: ${text.take(60)}")
|
|
||||||
agent.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `agent exposes memory tools when store is configured`() {
|
|
||||||
val system = openMdMemorySystem(root)
|
|
||||||
val tools = MemoryToolsFactory.create(system.store)
|
|
||||||
assertEquals(4, tools.size)
|
|
||||||
val names = tools.map { it.name }.toSet()
|
|
||||||
assertEquals(setOf("memory_save", "memory_read", "memory_list", "memory_delete"), names)
|
|
||||||
// Каждый tool описывается валидной JSON-схемой:
|
|
||||||
for (t in tools) {
|
|
||||||
assertTrue(t.tool.describe().contains("\"description\""), "describe() for ${t.name}")
|
|
||||||
}
|
|
||||||
system.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `memory_save tool round-trips a note through the store`() = runBlocking {
|
|
||||||
val system = openMdMemorySystem(root)
|
|
||||||
val tools = MemoryToolsFactory.create(system.store).associateBy { it.name }
|
|
||||||
val saveResult = tools.getValue("memory_save").tool.invoke(
|
|
||||||
"""{"category":"preference","content":"prefers tabs over spaces"}""",
|
|
||||||
)
|
|
||||||
assertTrue(saveResult.contains("\"ok\":true"), "save returned: $saveResult")
|
|
||||||
assertTrue(saveResult.contains("\"id\":\"mem-"), "save returned: $saveResult")
|
|
||||||
|
|
||||||
val listResult = tools.getValue("memory_list").tool.invoke("""{"limit":10}""")
|
|
||||||
assertTrue(listResult.contains("prefers tabs over spaces"),
|
|
||||||
"list returned: $listResult")
|
|
||||||
|
|
||||||
val readResult = tools.getValue("memory_read").tool.invoke(
|
|
||||||
"""{"query":"tabs","top_k":3}""",
|
|
||||||
)
|
|
||||||
assertTrue(readResult.contains("prefers tabs over spaces"),
|
|
||||||
"read returned: $readResult")
|
|
||||||
|
|
||||||
val deleteResult = tools.getValue("memory_delete").tool.invoke(
|
|
||||||
Regex("\"id\":\"(mem-[^\"]+)\"").find(saveResult)?.let { m ->
|
|
||||||
"""{"id":"${m.groupValues[1]}"}"""
|
|
||||||
} ?: error("save did not return id"),
|
|
||||||
)
|
|
||||||
assertTrue(deleteResult.contains("\"ok\":true"), "delete returned: $deleteResult")
|
|
||||||
system.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `prefetch inserts memory context into first user message`() = runTest {
|
|
||||||
// Сидим факт в store.
|
|
||||||
val store = openMdMemorySystem(root).also {
|
|
||||||
it.store.upsert(
|
|
||||||
MemoryNote(
|
|
||||||
id = "mem-pre",
|
|
||||||
category = MemoryCategory.USER,
|
|
||||||
content = "User runs k3s on Debian",
|
|
||||||
createdAt = Instant.parse("2026-09-14T10:00:00Z"),
|
|
||||||
lastUsedAt = Instant.parse("2026-09-14T10:00:00Z"),
|
|
||||||
useCount = 0,
|
|
||||||
source = MemorySource.AGENT_SAVE,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val prefetcher = StaticPrefetcher { q, k ->
|
|
||||||
store.store.search(MemorySearchQuery(query = q, topK = k, category = null))
|
|
||||||
}
|
|
||||||
fakeLlm.reply = "ok"
|
|
||||||
|
|
||||||
val agent = newAgent(store.store, prefetcher, NoopReviewer())
|
|
||||||
val conv = agent.createConversation(temp = false) as ChatConversation
|
|
||||||
conv.send(listOf(Content.Text("what's my k3s setup?")))
|
|
||||||
|
|
||||||
val sentText = fakeLlm.lastContents?.filterIsInstance<pw.binom.litert.LiteContentPart.Text>()
|
|
||||||
?.joinToString("\n") { it.text }
|
|
||||||
assertNotNull(sentText)
|
|
||||||
assertTrue(sentText.startsWith("[Memory context"),
|
|
||||||
"user message should start with memory prefix, got: $sentText")
|
|
||||||
assertTrue(sentText.contains("User runs k3s on Debian"),
|
|
||||||
"user message should include the prefetched note, got: $sentText")
|
|
||||||
assertTrue(sentText.contains("what's my k3s setup?"),
|
|
||||||
"user message should still contain the original text after the prefix, got: $sentText")
|
|
||||||
agent.close()
|
|
||||||
store.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `prefetch does not add prefix when no hits`() = runTest {
|
|
||||||
val store = openMdMemorySystem(root)
|
|
||||||
val prefetcher = StaticPrefetcher { _, _ -> emptyList() }
|
|
||||||
fakeLlm.reply = "ok"
|
|
||||||
|
|
||||||
val agent = newAgent(store.store, prefetcher, NoopReviewer())
|
|
||||||
val conv = agent.createConversation(temp = false) as ChatConversation
|
|
||||||
conv.send(listOf(Content.Text("hello")))
|
|
||||||
|
|
||||||
val sentText = fakeLlm.lastContents?.filterIsInstance<pw.binom.litert.LiteContentPart.Text>()
|
|
||||||
?.joinToString("\n") { it.text }
|
|
||||||
assertNotNull(sentText)
|
|
||||||
assertTrue(!sentText.startsWith("[Memory context"),
|
|
||||||
"user message should not start with prefix when no hits, got: $sentText")
|
|
||||||
assertTrue(sentText.contains("hello"))
|
|
||||||
agent.close()
|
|
||||||
store.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `reviewer fires on compaction (event-driven), not on every turn`() = runTest {
|
|
||||||
// После перехода на event-driven: review fires ТОЛЬКО в CompactionCoordinator.compactPreTurn()
|
|
||||||
// (через reviewer.reviewPreCompaction()) — не на каждый turn, не по interval'у.
|
|
||||||
// На простом turn без compaction review НЕ запускается.
|
|
||||||
val store = openMdMemorySystem(root)
|
|
||||||
fakeLlm.reply = "Sure, I'll remember that."
|
|
||||||
val reviewerInvoked = CompletableDeferred<Unit>()
|
|
||||||
val reviewer = object : MemoryReviewer {
|
|
||||||
override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision =
|
|
||||||
error("review() не должен вызываться — только reviewPreCompaction() на compaction")
|
|
||||||
override suspend fun reviewPreCompaction(turns: List<ConversationTurn>): MemoryReviewDecision {
|
|
||||||
reviewerInvoked.complete(Unit)
|
|
||||||
return MemoryReviewDecision(
|
|
||||||
toSave = listOf(NewMemoryNote(MemoryCategory.PREFERENCE, "prefers k8s")),
|
|
||||||
toDelete = emptyList(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val agent = newAgent(
|
|
||||||
store.store,
|
|
||||||
StaticPrefetcher { _, _ -> emptyList() },
|
|
||||||
reviewer,
|
|
||||||
contextWindow = 1024, // forceCompactNow требует contextWindow
|
|
||||||
)
|
|
||||||
val conv = agent.createConversation(temp = false) as ChatConversation
|
|
||||||
|
|
||||||
// Отправляем turn — ничего не должно триггериться, т.к. event-driven review
|
|
||||||
// fires только на compaction/closing.
|
|
||||||
conv.send(listOf(Content.Text("please note: I prefer k8s over docker swarm")))
|
|
||||||
|
|
||||||
// Ждём немного, проверяем что reviewer.review() НЕ был вызван
|
|
||||||
withContext(Dispatchers.Default.limitedParallelism(1)) {
|
|
||||||
delay(200)
|
|
||||||
}
|
|
||||||
assertTrue(store.store.list(category = MemoryCategory.PREFERENCE).isEmpty(),
|
|
||||||
"review НЕ должен был вызваться на простом turn без compaction")
|
|
||||||
|
|
||||||
// Триггерим compaction вручную (через debug-API ConversationLoop.forceCompactNow).
|
|
||||||
// Это вызывает CompactionCoordinator.compactPreTurn → reviewer.reviewPreCompaction.
|
|
||||||
conv.forceCompactNow()
|
|
||||||
|
|
||||||
// Дожидаемся reviewPreCompaction и upsert'а в IO-диспетчере.
|
|
||||||
withContext(Dispatchers.Default.limitedParallelism(1)) {
|
|
||||||
withTimeout(2_000) { reviewerInvoked.await() }
|
|
||||||
withTimeout(2_000) {
|
|
||||||
while (store.store.list(category = MemoryCategory.PREFERENCE).isEmpty()) delay(20)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val notes = store.store.list(category = MemoryCategory.PREFERENCE)
|
|
||||||
assertEquals(1, notes.size, "reviewPreCompaction должен сохранить заметку")
|
|
||||||
assertEquals("prefers k8s", notes[0].content)
|
|
||||||
assertEquals(MemorySource.AUTO_REVIEW, notes[0].source)
|
|
||||||
agent.close()
|
|
||||||
store.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- helpers ---
|
|
||||||
|
|
||||||
private class StaticPrefetcher(
|
|
||||||
private val fn: suspend (String, Int) -> List<MemorySearchResult>,
|
|
||||||
) : MemoryPrefetcher {
|
|
||||||
override suspend fun prefetch(query: String, topK: Int, category: MemoryCategory?): List<MemoryNote> {
|
|
||||||
if (query.isBlank()) return emptyList()
|
|
||||||
return fn(query, topK).map { it.note }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class NoopReviewer : MemoryReviewer {
|
|
||||||
override suspend fun review(turn: ReviewedTurn): MemoryReviewDecision =
|
|
||||||
MemoryReviewDecision(toSave = emptyList(), toDelete = emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Простой compactor для тестов: возвращает статичную строку. */
|
|
||||||
private object EchoCompactor : ContextCompactor {
|
|
||||||
override suspend fun summarize(turns: List<SummaryTurn>): String =
|
|
||||||
if (turns.isEmpty()) "" else "compacted-${turns.size}-turns"
|
|
||||||
}
|
|
||||||
-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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(""))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
|
||||||
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
import java.io.File
|
|
||||||
import kotlin.uuid.Uuid
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import pw.binom.agentik.skills.DiskSkillStore
|
|
||||||
import pw.binom.agentik.skills.SkillCatalog
|
|
||||||
import pw.binom.agentik.skills.SkillFile
|
|
||||||
import pw.binom.agentik.skills.SkillStore
|
|
||||||
|
|
||||||
class SkillToolsTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `SkillSaveTool persists and surfaces in catalog`() {
|
|
||||||
val dir = tempSkillDir()
|
|
||||||
val store: SkillStore = DiskSkillStore(dir)
|
|
||||||
val tool = SkillSaveTool(store)
|
|
||||||
|
|
||||||
val arguments = """{"name":"my-skill","description":"Test skill","body":"# Hello"}"""
|
|
||||||
val result = tool.invoke(arguments)
|
|
||||||
assertTrue(result.contains("\"ok\":true"), "expected success, got: $result")
|
|
||||||
assertTrue(result.contains("my-skill"))
|
|
||||||
|
|
||||||
runBlocking {
|
|
||||||
val reloaded = SkillCatalog(store.catalog.skills)
|
|
||||||
assertEquals(1, reloaded.skills.size)
|
|
||||||
val s = reloaded.skills.first()
|
|
||||||
assertEquals("my-skill", s.name)
|
|
||||||
assertEquals("Test skill", s.description)
|
|
||||||
assertEquals("# Hello", s.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Должен появиться файл на диске
|
|
||||||
assertTrue(File(dir, "my-skill/SKILL.md").exists())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `SkillSaveTool rejects blank name`() {
|
|
||||||
val dir = tempSkillDir()
|
|
||||||
val store = DiskSkillStore(dir)
|
|
||||||
val tool = SkillSaveTool(store)
|
|
||||||
val arguments = """{"name":"","description":"x","body":"y"}"""
|
|
||||||
// Ожидаем ошибку
|
|
||||||
try {
|
|
||||||
tool.invoke(arguments)
|
|
||||||
error("should have thrown")
|
|
||||||
} catch (e: IllegalStateException) {
|
|
||||||
assertTrue(e.message!!.contains("skill name", ignoreCase = true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `SkillDeleteTool archives the skill`() {
|
|
||||||
val dir = tempSkillDir()
|
|
||||||
val store = DiskSkillStore(dir)
|
|
||||||
store.upsert(SkillFile(name = "to-delete", description = "x", body = "y"))
|
|
||||||
|
|
||||||
val tool = SkillDeleteTool(store)
|
|
||||||
val result = tool.invoke("""{"name":"to-delete"}""")
|
|
||||||
assertTrue(result.contains("\"ok\":true"))
|
|
||||||
assertTrue(result.contains("to-delete"))
|
|
||||||
|
|
||||||
// Скил исчез из каталога
|
|
||||||
assertTrue(store.catalog.skills.none { it.name == "to-delete" })
|
|
||||||
// Файл переименован в .archived
|
|
||||||
assertTrue(File(dir, "to-delete/SKILL.md.archived").exists())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `SkillDeleteTool returns error for missing skill`() {
|
|
||||||
val dir = tempSkillDir()
|
|
||||||
val store = DiskSkillStore(dir)
|
|
||||||
val tool = SkillDeleteTool(store)
|
|
||||||
try {
|
|
||||||
tool.invoke("""{"name":"does-not-exist"}""")
|
|
||||||
error("should have thrown")
|
|
||||||
} catch (e: IllegalStateException) {
|
|
||||||
assertTrue(e.message!!.contains("not found", ignoreCase = true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `colon-named skills map to nested directories`() {
|
|
||||||
val dir = tempSkillDir()
|
|
||||||
val store = DiskSkillStore(dir)
|
|
||||||
store.upsert(SkillFile(name = "backend:spring:db-base", description = "x", body = "y"))
|
|
||||||
assertTrue(File(dir, "backend/spring/db-base/SKILL.md").exists())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun tempSkillDir(): File {
|
|
||||||
val dir = File(System.getProperty("java.io.tmpdir"), "agentik-skills-${Uuid.random()}")
|
|
||||||
dir.deleteOnExit()
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package pw.binom.agentik.standalone.agent
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import pw.binom.agentik.memory.MemoryCategory
|
|
||||||
import pw.binom.agentik.memory.MemoryNote
|
|
||||||
import pw.binom.agentik.memory.MemorySearchQuery
|
|
||||||
import pw.binom.agentik.memory.MemorySearchResult
|
|
||||||
import pw.binom.agentik.memory.MemoryStore
|
|
||||||
import pw.binom.agentik.memory.MemoryStoreEvent
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Простой in-memory MemoryStore для тестов.
|
|
||||||
*
|
|
||||||
* Не зависит от :memory-md / :memory-vector; идёт через `archiveStale` из
|
|
||||||
* default-имплементации MemoryStore.
|
|
||||||
*/
|
|
||||||
class TestInMemoryMemoryStore : MemoryStore {
|
|
||||||
private val notes = mutableMapOf<String, MemoryNote>()
|
|
||||||
private val ev = MutableSharedFlow<MemoryStoreEvent>(extraBufferCapacity = 16)
|
|
||||||
|
|
||||||
override suspend fun upsert(note: MemoryNote) {
|
|
||||||
notes[note.id] = note
|
|
||||||
ev.tryEmit(MemoryStoreEvent.Upserted(note))
|
|
||||||
}
|
|
||||||
override suspend fun get(id: String): MemoryNote? = notes[id]
|
|
||||||
override suspend fun list(
|
|
||||||
category: MemoryCategory?,
|
|
||||||
conversationId: String?,
|
|
||||||
limit: Int,
|
|
||||||
offset: Int,
|
|
||||||
): List<MemoryNote> =
|
|
||||||
notes.values
|
|
||||||
.filter { category == null || it.category == category }
|
|
||||||
.drop(offset)
|
|
||||||
.take(limit)
|
|
||||||
override suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> =
|
|
||||||
notes.values
|
|
||||||
.filter { query.category == null || it.category == query.category }
|
|
||||||
.map { MemorySearchResult(it, 1.0f) }
|
|
||||||
.take(query.topK)
|
|
||||||
override suspend fun delete(id: String): Boolean {
|
|
||||||
val ok = notes.remove(id) != null
|
|
||||||
if (ok) ev.tryEmit(MemoryStoreEvent.Deleted(id))
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
override suspend fun markUsed(id: String, at: Instant) {
|
|
||||||
notes[id]?.let { notes[id] = it.copy(lastUsedAt = at, useCount = it.useCount + 1) }
|
|
||||||
}
|
|
||||||
override fun events(): Flow<MemoryStoreEvent> = ev.asSharedFlow()
|
|
||||||
override fun close() {}
|
|
||||||
fun allIds(): List<String> = notes.keys.sorted()
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
package pw.binom.agentik.standalone.agent.memory
|
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import pw.binom.agentik.standalone.agent.TestInMemoryMemoryStore
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Duration
|
|
||||||
import kotlin.time.Instant
|
|
||||||
import pw.binom.agentik.memory.MemoryCategory
|
|
||||||
import pw.binom.agentik.memory.MemoryNote
|
|
||||||
import pw.binom.agentik.memory.MemorySource
|
|
||||||
|
|
||||||
class CuratorTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `archives notes older than maxAge with zero useCount`() = runTest {
|
|
||||||
val store = TestInMemoryMemoryStore()
|
|
||||||
val now = Instant.parse("2026-09-15T00:00:00Z")
|
|
||||||
store.upsert(note("old", lastUsedOffsetDays = 100, useCount = 0))
|
|
||||||
store.upsert(note("fresh", lastUsedOffsetDays = 1, useCount = 0))
|
|
||||||
val curator = Curator(store, maxAge = Duration.parse("90d"), clock = FakeClock(now))
|
|
||||||
assertEquals(1, curator.runPass())
|
|
||||||
assertEquals(listOf("fresh"), store.allIds())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `keeps notes that were used recently even if old`() = runTest {
|
|
||||||
val store = TestInMemoryMemoryStore()
|
|
||||||
val now = Instant.parse("2026-09-15T00:00:00Z")
|
|
||||||
store.upsert(note("frequently-used", lastUsedOffsetDays = 1, useCount = 50))
|
|
||||||
val curator = Curator(store, maxAge = Duration.parse("90d"), clock = FakeClock(now))
|
|
||||||
assertEquals(0, curator.runPass())
|
|
||||||
assertEquals(listOf("frequently-used"), store.allIds())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `archives notes with useCount above default when configured`() = runTest {
|
|
||||||
val store = TestInMemoryMemoryStore()
|
|
||||||
val now = Instant.parse("2026-09-15T00:00:00Z")
|
|
||||||
store.upsert(note("twice-used", lastUsedOffsetDays = 100, useCount = 2))
|
|
||||||
val curator = Curator(
|
|
||||||
store,
|
|
||||||
maxAge = Duration.parse("90d"),
|
|
||||||
maxUseCount = 5,
|
|
||||||
clock = FakeClock(now),
|
|
||||||
)
|
|
||||||
assertEquals(1, curator.runPass())
|
|
||||||
assertTrue(store.allIds().isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `start and stop launch and cancel the background loop`() = runTest {
|
|
||||||
val store = TestInMemoryMemoryStore()
|
|
||||||
val curator = Curator(
|
|
||||||
store,
|
|
||||||
interval = Duration.parse("10ms"),
|
|
||||||
maxAge = Duration.parse("90d"),
|
|
||||||
)
|
|
||||||
curator.start()
|
|
||||||
Thread.sleep(50)
|
|
||||||
curator.stop()
|
|
||||||
// ничего не падает, корутина отменена
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun note(
|
|
||||||
id: String,
|
|
||||||
lastUsedOffsetDays: Long,
|
|
||||||
useCount: Int,
|
|
||||||
): MemoryNote {
|
|
||||||
val now = Instant.parse("2026-09-15T00:00:00Z")
|
|
||||||
return MemoryNote(
|
|
||||||
id = id,
|
|
||||||
category = MemoryCategory.WORLD,
|
|
||||||
content = "fact $id",
|
|
||||||
createdAt = now - Duration.parse("${lastUsedOffsetDays}d"),
|
|
||||||
lastUsedAt = now - Duration.parse("${lastUsedOffsetDays}d"),
|
|
||||||
useCount = useCount,
|
|
||||||
conversationId = null,
|
|
||||||
source = MemorySource.AGENT_SAVE,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private class FakeClock(private val now: Instant) : Clock {
|
|
||||||
override fun now(): Instant = now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
package pw.binom.agentik.standalone.config
|
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import pw.binom.agentik.standalone.llm.LlmBackend
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertFailsWith
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
|
|
||||||
class AppConfigTest {
|
|
||||||
|
|
||||||
private fun openAiEnv(
|
|
||||||
extra: Map<String, String> = emptyMap(),
|
|
||||||
): (String) -> String? = { name ->
|
|
||||||
when (name) {
|
|
||||||
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
|
||||||
"OPENAI_API_KEY" -> "sk-test"
|
|
||||||
"OPENAI_MODEL" -> "gpt-4o-mini"
|
|
||||||
else -> extra[name]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `defaults applied when port and db path absent`() {
|
|
||||||
val cfg = AppConfig.fromEnv(openAiEnv())
|
|
||||||
assertEquals(AppConfig.DEFAULT_PORT, cfg.agent.port)
|
|
||||||
assertEquals(AppConfig.DEFAULT_DB_PATH, cfg.agent.dbPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `reads port and db path from env`() {
|
|
||||||
val cfg = AppConfig.fromEnv(
|
|
||||||
openAiEnv(mapOf("AGENTIK_PORT" to "9999", "AGENTIK_DB_PATH" to "/tmp/x.db")),
|
|
||||||
)
|
|
||||||
assertEquals(9999, cfg.agent.port)
|
|
||||||
assertEquals("/tmp/x.db", cfg.agent.dbPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `blank db path falls back to default`() {
|
|
||||||
val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_DB_PATH" to " ")))
|
|
||||||
assertEquals(AppConfig.DEFAULT_DB_PATH, cfg.agent.dbPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `non-numeric port falls back to default`() {
|
|
||||||
val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_PORT" to "not-a-port")))
|
|
||||||
assertEquals(AppConfig.DEFAULT_PORT, cfg.agent.port)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `delegates llm to AppConfig fromEnv`() {
|
|
||||||
val cfg = AppConfig.fromEnv(openAiEnv())
|
|
||||||
assertEquals(LlmBackend.OPENAI, cfg.llm.backend)
|
|
||||||
assertEquals("gpt-4o-mini", cfg.llm.openai?.model)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `google backend is selected via env`() {
|
|
||||||
val cfg = AppConfig.fromEnv { name ->
|
|
||||||
when (name) {
|
|
||||||
"AGENTIK_LLM_BACKEND" -> "google"
|
|
||||||
"AGENTIK_GOOGLE_MODEL_PATH" -> "/models/gemma.litertlm"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assertEquals(LlmBackend.GOOGLE, cfg.llm.backend)
|
|
||||||
assertEquals("/models/gemma.litertlm", cfg.llm.google?.modelPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `missing required llm env propagates`() {
|
|
||||||
assertFailsWith<IllegalStateException> {
|
|
||||||
AppConfig.fromEnv { name -> if (name == "OPENAI_BASE_URL") "x" else null }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `mcp empty when no config path`() {
|
|
||||||
val cfg = AppConfig.fromEnv(openAiEnv())
|
|
||||||
assertTrue(cfg.mcp.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `mcp loaded from file`() {
|
|
||||||
val tmp = kotlin.io.path.createTempFile(suffix = ".json").toFile()
|
|
||||||
try {
|
|
||||||
tmp.writeText(
|
|
||||||
"""
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"fetch": { "command": "uvx", "args": ["mcp-server-fetch"] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""".trimIndent(),
|
|
||||||
)
|
|
||||||
val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_MCP_CONFIG" to tmp.absolutePath)))
|
|
||||||
assertEquals(1, cfg.mcp.servers.size)
|
|
||||||
assertEquals("fetch", cfg.mcp.servers.first().name)
|
|
||||||
} finally {
|
|
||||||
tmp.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `skills dir defaults to null`() {
|
|
||||||
assertEquals(null, AppConfig.fromEnv(openAiEnv()).agent.skillsDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `skills dir read from env`() {
|
|
||||||
val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to "/skills")))
|
|
||||||
assertEquals("/skills", cfg.agent.skillsDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `blank skills dir falls back to null`() {
|
|
||||||
val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SKILLS_DIR" to " ")))
|
|
||||||
assertEquals(null, cfg.agent.skillsDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `soul path defaults to null`() {
|
|
||||||
assertEquals(null, AppConfig.fromEnv(openAiEnv()).agent.soulPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `soul path read from env`() {
|
|
||||||
val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SOUL" to "/etc/SOUL.md")))
|
|
||||||
assertEquals("/etc/SOUL.md", cfg.agent.soulPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `blank soul path falls back to null`() {
|
|
||||||
val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_SOUL" to " ")))
|
|
||||||
assertEquals(null, cfg.agent.soulPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `serialization round-trips through json`() {
|
|
||||||
val original = AppConfig.fromEnv(
|
|
||||||
openAiEnv(
|
|
||||||
mapOf(
|
|
||||||
"AGENTIK_PORT" to "7777",
|
|
||||||
"AGENTIK_DB_PATH" to "/tmp/x.db",
|
|
||||||
"AGENTIK_SYSTEM_PROMPT" to "be brief",
|
|
||||||
"AGENTIK_SKILLS_DIR" to "/skills",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).copy(
|
|
||||||
mcp = pw.binom.agentik.mcp.bridge.McpConfig(
|
|
||||||
servers = listOf(
|
|
||||||
pw.binom.agentik.mcp.bridge.McpServerSpec.Stdio(
|
|
||||||
name = "fetch",
|
|
||||||
command = "uvx",
|
|
||||||
args = listOf("mcp-server-fetch"),
|
|
||||||
),
|
|
||||||
pw.binom.agentik.mcp.bridge.McpServerSpec.Http(
|
|
||||||
name = "remote",
|
|
||||||
url = "https://example.com/mcp",
|
|
||||||
headers = mapOf("Authorization" to "Bearer x"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
val json = Json { encodeDefaults = true }
|
|
||||||
val text = json.encodeToString(AppConfig.serializer(), original)
|
|
||||||
val restored = json.decodeFromString(AppConfig.serializer(), text)
|
|
||||||
|
|
||||||
assertEquals(original, restored)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `compressionThreshold defaults to 0_8 when env unset`() {
|
|
||||||
val cfg = AppConfig.fromEnv(openAiEnv())
|
|
||||||
assertEquals(0.8, cfg.memory.compressionThreshold)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `compressionThreshold parsed from env`() {
|
|
||||||
val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "0.6")))
|
|
||||||
assertEquals(0.6, cfg.memory.compressionThreshold)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `compressionThreshold clamped between min and max`() {
|
|
||||||
val tooLow = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "0.01")))
|
|
||||||
assertEquals(0.1, tooLow.memory.compressionThreshold)
|
|
||||||
val tooHigh = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "1.5")))
|
|
||||||
assertEquals(0.99, tooHigh.memory.compressionThreshold)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `compressionThreshold garbage falls back to default`() {
|
|
||||||
val cfg = AppConfig.fromEnv(openAiEnv(mapOf("AGENTIK_COMPRESSION_THRESHOLD" to "хрен")))
|
|
||||||
assertEquals(0.8, cfg.memory.compressionThreshold)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
package pw.binom.agentik.standalone.llm
|
|
||||||
|
|
||||||
import pw.binom.agentik.standalone.config.AppConfig
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertFailsWith
|
|
||||||
|
|
||||||
class LlmConfigTest {
|
|
||||||
|
|
||||||
private fun openAiEnv(extra: Map<String, String> = emptyMap()): (String) -> String? = { name ->
|
|
||||||
when (name) {
|
|
||||||
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
|
||||||
"OPENAI_API_KEY" -> "sk-test"
|
|
||||||
"OPENAI_MODEL" -> "gpt-4o-mini"
|
|
||||||
else -> extra[name]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `fromEnv - happy path`() {
|
|
||||||
val cfg = AppConfig.fromEnv { name ->
|
|
||||||
when (name) {
|
|
||||||
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
|
||||||
"OPENAI_API_KEY" -> "sk-test"
|
|
||||||
"OPENAI_MODEL" -> "gpt-4o-mini"
|
|
||||||
"AGENTIK_SYSTEM_PROMPT" -> "be brief"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}.llm
|
|
||||||
assertEquals("be brief", cfg.systemPrompt)
|
|
||||||
assertEquals(OpenAiConfig(baseUrl = "https://api.openai.com/v1", apiKey = "sk-test", model = "gpt-4o-mini"), cfg.openai)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `fromEnv - falls back to default system prompt`() {
|
|
||||||
val cfg = AppConfig.fromEnv(openAiEnv()).llm
|
|
||||||
assertEquals(LlmConfig.DEFAULT_SYSTEM_PROMPT, cfg.systemPrompt)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `fromEnv - missing base url throws`() {
|
|
||||||
assertFailsWith<IllegalStateException> {
|
|
||||||
AppConfig.fromEnv { name ->
|
|
||||||
when (name) {
|
|
||||||
"OPENAI_API_KEY" -> "sk-test"
|
|
||||||
"OPENAI_MODEL" -> "gpt-4o-mini"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `fromEnv - missing api key throws`() {
|
|
||||||
assertFailsWith<IllegalStateException> {
|
|
||||||
AppConfig.fromEnv { name ->
|
|
||||||
when (name) {
|
|
||||||
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
|
||||||
"OPENAI_MODEL" -> "gpt-4o-mini"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `fromEnv - missing model throws`() {
|
|
||||||
assertFailsWith<IllegalStateException> {
|
|
||||||
AppConfig.fromEnv { name ->
|
|
||||||
when (name) {
|
|
||||||
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
|
||||||
"OPENAI_API_KEY" -> "sk-test"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `blank system prompt from env falls back to default`() {
|
|
||||||
val cfg = AppConfig.fromEnv { name ->
|
|
||||||
when (name) {
|
|
||||||
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
|
||||||
"OPENAI_API_KEY" -> "sk-test"
|
|
||||||
"OPENAI_MODEL" -> "gpt-4o-mini"
|
|
||||||
"AGENTIK_SYSTEM_PROMPT" -> " "
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}.llm
|
|
||||||
assertEquals(LlmConfig.DEFAULT_SYSTEM_PROMPT, cfg.systemPrompt)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `fromEnv - OPENAI_CONTEXT_WINDOW parsed into OpenAiConfig`() {
|
|
||||||
val cfg = AppConfig.fromEnv { name ->
|
|
||||||
when (name) {
|
|
||||||
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
|
||||||
"OPENAI_API_KEY" -> "sk-test"
|
|
||||||
"OPENAI_MODEL" -> "gpt-4o-mini"
|
|
||||||
"OPENAI_CONTEXT_WINDOW" -> "128000"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}.llm
|
|
||||||
assertEquals(128_000, cfg.openai?.contextWindow)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `contextWindow - env parsed once and exposed via derived prop`() {
|
|
||||||
// Старый resolveContextWindow проверял "env wins over config" — но в новой
|
|
||||||
// модели env и config это одно и то же значение (env читается один раз в
|
|
||||||
// AppConfig.fromEnv и сохраняется в OpenAiConfig.contextWindow). Поэтому
|
|
||||||
// тут проверяем, что derived-prop LlmConfig.contextWindow правильно
|
|
||||||
// прокидывает значение из OpenAiConfig для активного бэкенда.
|
|
||||||
val cfg = AppConfig.fromEnv { name ->
|
|
||||||
when (name) {
|
|
||||||
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
|
||||||
"OPENAI_API_KEY" -> "sk-test"
|
|
||||||
"OPENAI_MODEL" -> "gpt-4o-mini"
|
|
||||||
"OPENAI_CONTEXT_WINDOW" -> "64000"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}.llm
|
|
||||||
assertEquals(64_000, cfg.contextWindow)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `contextWindow - returns null when nothing set`() {
|
|
||||||
val cfg = AppConfig.fromEnv(openAiEnv()).llm
|
|
||||||
assertEquals(null, cfg.contextWindow)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `fromEnv - OPENAI_CONTEXT_WINDOW garbage falls back to null`() {
|
|
||||||
val cfg = AppConfig.fromEnv { name ->
|
|
||||||
when (name) {
|
|
||||||
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
|
|
||||||
"OPENAI_API_KEY" -> "sk-test"
|
|
||||||
"OPENAI_MODEL" -> "gpt-4o-mini"
|
|
||||||
"OPENAI_CONTEXT_WINDOW" -> "не-число"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}.llm
|
|
||||||
assertEquals(null, cfg.openai?.contextWindow)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
package pw.binom.agentik.standalone.llm
|
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.server.application.call
|
|
||||||
import io.ktor.server.cio.CIO
|
|
||||||
import io.ktor.server.engine.embeddedServer
|
|
||||||
import io.ktor.server.response.respondBytes
|
|
||||||
import io.ktor.server.response.respondText
|
|
||||||
import io.ktor.server.routing.get
|
|
||||||
import io.ktor.server.routing.head
|
|
||||||
import io.ktor.server.routing.routing
|
|
||||||
import io.ktor.http.HttpStatusCode
|
|
||||||
import io.ktor.http.HttpHeaders as KH
|
|
||||||
import io.ktor.utils.io.toByteArray
|
|
||||||
import java.io.File
|
|
||||||
import java.net.ServerSocket
|
|
||||||
import java.nio.file.Path
|
|
||||||
import kotlin.io.path.createTempDirectory
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertContentEquals
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
import kotlin.test.fail
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
|
|
||||||
class ModelDownloaderTest {
|
|
||||||
|
|
||||||
private val payload = ByteArray(8192) { (it and 0xff).toByte() }
|
|
||||||
|
|
||||||
/** Поднимает fake-HTTP-server с поддержкой HEAD/GET/Range и возвращает `port`. */
|
|
||||||
private fun startFakeServer(): FakeServer {
|
|
||||||
val port = ServerSocket(0).use { it.localPort }
|
|
||||||
val server = embeddedServer(CIO, port = port) {
|
|
||||||
routing {
|
|
||||||
head("/model.litertlm") {
|
|
||||||
call.response.headers.append(KH.AcceptRanges, "bytes")
|
|
||||||
call.response.headers.append(KH.ContentLength, payload.size.toString())
|
|
||||||
call.respondText("")
|
|
||||||
}
|
|
||||||
get("/model.litertlm") {
|
|
||||||
val range = call.request.headers[KH.Range]
|
|
||||||
if (range == null) {
|
|
||||||
call.response.headers.append(KH.ContentLength, payload.size.toString())
|
|
||||||
call.respondBytes(payload)
|
|
||||||
} else {
|
|
||||||
// Parse "bytes=N-"
|
|
||||||
val n = range.substringAfter("bytes=").substringBefore('-').toLong()
|
|
||||||
val slice = payload.copyOfRange(n.toInt(), payload.size)
|
|
||||||
call.response.status(HttpStatusCode.PartialContent)
|
|
||||||
call.response.headers.append(KH.ContentRange, "bytes $n-${payload.size - 1}/${payload.size}")
|
|
||||||
call.response.headers.append(KH.ContentLength, slice.size.toString())
|
|
||||||
call.respondBytes(slice)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
server.start(wait = false)
|
|
||||||
return FakeServer(server, port)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun tmpFile(): File {
|
|
||||||
val dir: Path = createTempDirectory(prefix = "agentik-test-")
|
|
||||||
return dir.resolve("model.litertlm").toFile()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `download writes full file when no part exists`() = runBlocking {
|
|
||||||
val fake = startFakeServer()
|
|
||||||
try {
|
|
||||||
val dest = tmpFile()
|
|
||||||
val dl = ModelDownloader()
|
|
||||||
val result = dl.download(
|
|
||||||
url = "http://127.0.0.1:${fake.port}/model.litertlm",
|
|
||||||
destPath = dest.absolutePath,
|
|
||||||
)
|
|
||||||
assertEquals(payload.size.toLong(), result.bytes)
|
|
||||||
assertEquals(0L, result.resumedFrom)
|
|
||||||
assertContentEquals(payload, dest.readBytes())
|
|
||||||
assertTrue(!File("${dest.absolutePath}.part").exists(), "part file should be cleaned up")
|
|
||||||
} finally {
|
|
||||||
fake.server.stop(100, 200)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `download is no-op when final file already complete`() = runBlocking {
|
|
||||||
val fake = startFakeServer()
|
|
||||||
try {
|
|
||||||
val dest = tmpFile()
|
|
||||||
dest.writeBytes(payload)
|
|
||||||
val dl = ModelDownloader()
|
|
||||||
val result = dl.download(
|
|
||||||
url = "http://127.0.0.1:${fake.port}/model.litertlm",
|
|
||||||
destPath = dest.absolutePath,
|
|
||||||
)
|
|
||||||
assertEquals(0L, result.bytes)
|
|
||||||
assertEquals(payload.size.toLong(), result.total)
|
|
||||||
assertContentEquals(payload, dest.readBytes())
|
|
||||||
} finally {
|
|
||||||
fake.server.stop(100, 200)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `download resumes from existing part file with Range request`() = runBlocking {
|
|
||||||
val fake = startFakeServer()
|
|
||||||
try {
|
|
||||||
val dest = tmpFile()
|
|
||||||
val partFile = File("${dest.absolutePath}.part")
|
|
||||||
val prefixSize = 4096
|
|
||||||
partFile.writeBytes(payload.copyOfRange(0, prefixSize))
|
|
||||||
|
|
||||||
val dl = ModelDownloader()
|
|
||||||
val result = dl.download(
|
|
||||||
url = "http://127.0.0.1:${fake.port}/model.litertlm",
|
|
||||||
destPath = dest.absolutePath,
|
|
||||||
)
|
|
||||||
assertEquals(prefixSize.toLong(), result.resumedFrom)
|
|
||||||
assertEquals(payload.size.toLong(), result.bytes)
|
|
||||||
assertContentEquals(payload, dest.readBytes())
|
|
||||||
} finally {
|
|
||||||
fake.server.stop(100, 200)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `download reports progress via callback`() = runBlocking {
|
|
||||||
val fake = startFakeServer()
|
|
||||||
try {
|
|
||||||
val dest = tmpFile()
|
|
||||||
val dl = ModelDownloader()
|
|
||||||
val reports = mutableListOf<Pair<Long, Long>>()
|
|
||||||
dl.download(
|
|
||||||
url = "http://127.0.0.1:${fake.port}/model.litertlm",
|
|
||||||
destPath = dest.absolutePath,
|
|
||||||
progress = { d, t -> reports += d to t },
|
|
||||||
)
|
|
||||||
assertTrue(reports.isNotEmpty(), "progress must be reported at least once")
|
|
||||||
assertEquals(payload.size.toLong(), reports.last().first)
|
|
||||||
assertEquals(payload.size.toLong(), reports.last().second)
|
|
||||||
} finally {
|
|
||||||
fake.server.stop(100, 200)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `download fails with clear message on HTTP 404`() = runBlocking {
|
|
||||||
// Spin up server that always 404s
|
|
||||||
val port = ServerSocket(0).use { it.localPort }
|
|
||||||
val server = embeddedServer(CIO, port = port) {
|
|
||||||
routing {
|
|
||||||
head("/missing") { call.respondText("", status = HttpStatusCode.NotFound) }
|
|
||||||
get("/missing") { call.respondText("", status = HttpStatusCode.NotFound) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
server.start(wait = false)
|
|
||||||
try {
|
|
||||||
val dest = tmpFile()
|
|
||||||
val dl = ModelDownloader()
|
|
||||||
try {
|
|
||||||
dl.download(url = "http://127.0.0.1:$port/missing", destPath = dest.absolutePath)
|
|
||||||
fail("expected failure on 404")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
val msg = e.message ?: ""
|
|
||||||
assertTrue("404" in msg || "Not Found" in msg,
|
|
||||||
"error should mention HTTP 404, got: $msg")
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
server.stop(100, 200)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class FakeServer(val server: io.ktor.server.engine.EmbeddedServer<*, *>, val port: Int)
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
package pw.binom.agentik.standalone.mcp
|
|
||||||
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
import pw.binom.agentik.mcp.bridge.McpConfig
|
|
||||||
import pw.binom.agentik.mcp.bridge.McpServerSpec
|
|
||||||
|
|
||||||
class McpConfigTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `fromEnv returns empty when env var unset`() {
|
|
||||||
val cfg = McpConfig.fromEnv { null }
|
|
||||||
assertTrue(cfg.isEmpty)
|
|
||||||
assertEquals(emptyList(), cfg.servers)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `fromEnv returns empty when env var blank`() {
|
|
||||||
val cfg = McpConfig.fromEnv { "" }
|
|
||||||
assertTrue(cfg.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `fromEnv returns empty when file missing`() {
|
|
||||||
val cfg = McpConfig.fromEnv { "/tmp/agentik-nonexistent-mcp-${System.nanoTime()}.json" }
|
|
||||||
assertTrue(cfg.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `fromJson parses stdio server`() {
|
|
||||||
val json = """
|
|
||||||
{ "mcpServers": {
|
|
||||||
"fs": { "command": "npx", "args": ["-y", "fs-mcp"], "env": { "ROOT": "/work" } }
|
|
||||||
} }
|
|
||||||
""".trimIndent()
|
|
||||||
val cfg = McpConfig.fromJson(json)
|
|
||||||
assertEquals(1, cfg.servers.size)
|
|
||||||
val s = cfg.servers.single() as McpServerSpec.Stdio
|
|
||||||
assertEquals("fs", s.name)
|
|
||||||
assertEquals("npx", s.command)
|
|
||||||
assertEquals(listOf("-y", "fs-mcp"), s.args)
|
|
||||||
assertEquals(mapOf("ROOT" to "/work"), s.env)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `fromJson parses http server with headers`() {
|
|
||||||
val json = """
|
|
||||||
{ "mcpServers": {
|
|
||||||
"remote": { "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer X" } }
|
|
||||||
} }
|
|
||||||
""".trimIndent()
|
|
||||||
val cfg = McpConfig.fromJson(json)
|
|
||||||
val s = cfg.servers.single() as McpServerSpec.Http
|
|
||||||
assertEquals("remote", s.name)
|
|
||||||
assertEquals("https://example.com/mcp", s.url)
|
|
||||||
assertEquals(mapOf("Authorization" to "Bearer X"), s.headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `fromJson parses both stdio and http together`() {
|
|
||||||
val json = """
|
|
||||||
{ "mcpServers": {
|
|
||||||
"fs": { "command": "npx", "args": [] },
|
|
||||||
"remote":{ "url": "https://example.com/mcp" }
|
|
||||||
} }
|
|
||||||
""".trimIndent()
|
|
||||||
val cfg = McpConfig.fromJson(json)
|
|
||||||
assertEquals(2, cfg.servers.size)
|
|
||||||
assertTrue(cfg.servers.any { it is McpServerSpec.Stdio && it.name == "fs" })
|
|
||||||
assertTrue(cfg.servers.any { it is McpServerSpec.Http && it.name == "remote" })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `fromJson skips entries without command or url`() {
|
|
||||||
val json = """
|
|
||||||
{ "mcpServers": {
|
|
||||||
"broken": { "description": "no transport" }
|
|
||||||
} }
|
|
||||||
""".trimIndent()
|
|
||||||
val cfg = McpConfig.fromJson(json)
|
|
||||||
assertTrue(cfg.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `empty returns empty config`() {
|
|
||||||
val cfg = McpConfig.empty()
|
|
||||||
assertTrue(cfg.isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package pw.binom.agentik.standalone.mcp
|
|
||||||
|
|
||||||
import pw.binom.litert.LiteTool
|
|
||||||
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
|
|
||||||
|
|
||||||
class McpRegistryTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `empty config produces empty registry`() {
|
|
||||||
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`() {
|
|
||||||
val noop: LiteTool = object : LiteTool {
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `registry close is idempotent`() {
|
|
||||||
val registry = McpRegistry.fromConfig(McpConfig.empty())
|
|
||||||
registry.close()
|
|
||||||
registry.close() // should not throw
|
|
||||||
assertTrue(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-372
@@ -1,372 +0,0 @@
|
|||||||
package pw.binom.agentik.standalone.persistence
|
|
||||||
import pw.binom.agentik.journal.MessageContext
|
|
||||||
import pw.binom.agentik.journal.MessageOrigin
|
|
||||||
import pw.binom.agentik.journal.ConversationRecord
|
|
||||||
import pw.binom.agentik.journal.MessageRecord
|
|
||||||
import pw.binom.agentik.journal.Content
|
|
||||||
import pw.binom.agentik.context.WorkingMemoryEntry
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.toList
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import pw.binom.agentik.storage.ksqlite.KsqliteStores
|
|
||||||
import kotlin.test.AfterTest
|
|
||||||
import kotlin.test.BeforeTest
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertFalse
|
|
||||||
import kotlin.test.assertIs
|
|
||||||
import kotlin.test.assertNotNull
|
|
||||||
import kotlin.test.assertNull
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
class PersistenceTest {
|
|
||||||
|
|
||||||
private lateinit var stores: KsqliteStores
|
|
||||||
|
|
||||||
@BeforeTest
|
|
||||||
fun setup() {
|
|
||||||
stores = KsqliteStores.inMemory("persist-${kotlin.random.Random.nextLong()}")
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterTest
|
|
||||||
fun tearDown() {
|
|
||||||
stores.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `upsert + get conversation - roundtrip`() = runTest {
|
|
||||||
val now = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
|
||||||
val rec = ConversationRecord(
|
|
||||||
id = "c1",
|
|
||||||
title = "Hello",
|
|
||||||
isTemporal = false,
|
|
||||||
createdAt = now,
|
|
||||||
updatedAt = now,
|
|
||||||
)
|
|
||||||
stores.conversations.upsert(rec)
|
|
||||||
val got = stores.conversations.get("c1")
|
|
||||||
assertNotNull(got)
|
|
||||||
assertEquals(rec.id, got.id)
|
|
||||||
assertEquals(rec.title, got.title)
|
|
||||||
assertEquals(rec.isTemporal, got.isTemporal)
|
|
||||||
assertEquals(rec.createdAt, got.createdAt)
|
|
||||||
assertEquals(rec.updatedAt, got.updatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `upsert overwrites existing record`() = runTest {
|
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
|
||||||
stores.conversations.upsert(
|
|
||||||
ConversationRecord("c1", title = "A", isTemporal = false, createdAt = t0, updatedAt = t0),
|
|
||||||
)
|
|
||||||
val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000)
|
|
||||||
stores.conversations.upsert(
|
|
||||||
ConversationRecord("c1", title = "B", isTemporal = true, createdAt = t0, updatedAt = t1),
|
|
||||||
)
|
|
||||||
val got = stores.conversations.get("c1")!!
|
|
||||||
assertEquals("B", got.title)
|
|
||||||
assertTrue(got.isTemporal)
|
|
||||||
assertEquals(t1, got.updatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `list returns conversations ordered by updated_at desc`() = runTest {
|
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
|
||||||
repeat(3) { i ->
|
|
||||||
stores.conversations.upsert(
|
|
||||||
ConversationRecord(
|
|
||||||
id = "c$i",
|
|
||||||
title = null,
|
|
||||||
isTemporal = false,
|
|
||||||
createdAt = t0,
|
|
||||||
updatedAt = Instant.fromEpochMilliseconds(1_700_000_000_000 + i * 1000),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val list = stores.conversations.list(offset = 0, limit = 10)
|
|
||||||
assertEquals(listOf("c2", "c1", "c0"), list.map { it.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `delete cascades messages and working_memory`() = runTest {
|
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
|
||||||
stores.conversations.upsert(
|
|
||||||
ConversationRecord("c1", null, false, t0, t0),
|
|
||||||
)
|
|
||||||
stores.messages.append(
|
|
||||||
MessageRecord.UserMessage(
|
|
||||||
id = "m1",
|
|
||||||
conversationId = "c1",
|
|
||||||
content = listOf(Content.Text("hello")),
|
|
||||||
createdAt = t0,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
stores.workingMemory.append(
|
|
||||||
conversationId = "c1",
|
|
||||||
entry = WorkingMemoryEntry.User(
|
|
||||||
sourceMessageId = "m1",
|
|
||||||
content = listOf(Content.Text("hello")),
|
|
||||||
),
|
|
||||||
now = t0,
|
|
||||||
)
|
|
||||||
assertEquals(1, stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList().size)
|
|
||||||
assertEquals(1, stores.workingMemory.list("c1").size)
|
|
||||||
|
|
||||||
val removed = stores.conversations.delete("c1")
|
|
||||||
assertTrue(removed)
|
|
||||||
assertNull(stores.conversations.get("c1"))
|
|
||||||
assertEquals(emptyList(), stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList())
|
|
||||||
assertEquals(emptyList(), stores.workingMemory.list("c1"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `message audit log - append and read back`() = runTest {
|
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
|
||||||
stores.messages.append(
|
|
||||||
MessageRecord.UserMessage("m1", "c1", listOf(Content.Text("hi")), t0),
|
|
||||||
)
|
|
||||||
stores.messages.append(
|
|
||||||
MessageRecord.AssistantMessage("m2", "c1", listOf(Content.Text("yo")), t0),
|
|
||||||
)
|
|
||||||
|
|
||||||
val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()
|
|
||||||
assertEquals(2, all.size)
|
|
||||||
assertEquals("m1", all[0].id)
|
|
||||||
assertEquals("m2", all[1].id)
|
|
||||||
assertTrue(all[0] is MessageRecord.UserMessage)
|
|
||||||
assertTrue(all[1] is MessageRecord.AssistantMessage)
|
|
||||||
assertEquals("hi", (all[0] as MessageRecord.UserMessage).content[0].let {
|
|
||||||
(it as Content.Text).body
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `message after timestamp filter`() = runTest {
|
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
|
||||||
val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000)
|
|
||||||
stores.messages.append(MessageRecord.UserMessage("m1", "c1", listOf(Content.Text("a")), t0))
|
|
||||||
stores.messages.append(MessageRecord.UserMessage("m2", "c1", listOf(Content.Text("b")), t1))
|
|
||||||
|
|
||||||
val after = stores.messages.list("c1", after = t0, offset = 0, limit = 10)
|
|
||||||
assertEquals(1, after.size)
|
|
||||||
assertEquals("m2", after[0].id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `working memory - append + list preserves order`() = runTest {
|
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
|
||||||
val t1 = Instant.fromEpochMilliseconds(1_700_000_001_000)
|
|
||||||
stores.workingMemory.append(
|
|
||||||
conversationId = "c1",
|
|
||||||
entry = WorkingMemoryEntry.User(sourceMessageId = "m1", content = listOf(Content.Text("hi"))),
|
|
||||||
now = t0,
|
|
||||||
)
|
|
||||||
stores.workingMemory.append(
|
|
||||||
conversationId = "c1",
|
|
||||||
entry = WorkingMemoryEntry.Assistant(sourceMessageId = "m2", content = listOf(Content.Text("yo"))),
|
|
||||||
now = t1,
|
|
||||||
)
|
|
||||||
val list = stores.workingMemory.list("c1")
|
|
||||||
assertEquals(2, list.size)
|
|
||||||
assertTrue(list[0].entry is WorkingMemoryEntry.User)
|
|
||||||
assertTrue(list[1].entry is WorkingMemoryEntry.Assistant)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `working memory - compact without summary just drops tail`() = runTest {
|
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
|
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0)
|
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m3", listOf(Content.Text("u2"))), t0)
|
|
||||||
val rows = stores.workingMemory.list("c1")
|
|
||||||
// Drop начиная со второго хода (User m1) — должно остаться только User m1.
|
|
||||||
val dropFrom = rows[1].orderIdx
|
|
||||||
stores.workingMemory.compact(dropFrom, "c1", summaryText = null)
|
|
||||||
val after = stores.workingMemory.list("c1")
|
|
||||||
assertEquals(1, after.size)
|
|
||||||
assertTrue(after[0].entry is WorkingMemoryEntry.User)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `working memory - compact with summary inserts Summary entry`() = runTest {
|
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
|
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0)
|
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m3", listOf(Content.Text("u2"))), t0)
|
|
||||||
val rows = stores.workingMemory.list("c1")
|
|
||||||
val dropFrom = rows[1].orderIdx
|
|
||||||
stores.workingMemory.compact(dropFrom, "c1", summaryText = "**Goal**: chat\n**Active**: at u2\n**Resolved**: a1")
|
|
||||||
val after = stores.workingMemory.list("c1")
|
|
||||||
assertEquals(2, after.size)
|
|
||||||
assertTrue(after[0].entry is WorkingMemoryEntry.User)
|
|
||||||
val summary = after[1].entry
|
|
||||||
assertIs<WorkingMemoryEntry.Summary>(summary)
|
|
||||||
assertTrue(summary.text.startsWith("**Goal**"))
|
|
||||||
// order_idx должен быть > всех оставшихся
|
|
||||||
assertTrue(after[1].orderIdx > after[0].orderIdx)
|
|
||||||
// sourceMessageId у Summary всегда null
|
|
||||||
assertNull(after[1].sourceMessageId)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `working memory - compact with blank summaryText behaves as drop`() = runTest {
|
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
|
|
||||||
val rows = stores.workingMemory.list("c1")
|
|
||||||
stores.workingMemory.compact(rows[0].orderIdx + 1, "c1", summaryText = "")
|
|
||||||
val after = stores.workingMemory.list("c1")
|
|
||||||
assertEquals(1, after.size)
|
|
||||||
assertTrue(after[0].entry is WorkingMemoryEntry.User)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `working memory - compact is atomic on other conversations`() = runTest {
|
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.User("m1", listOf(Content.Text("u1"))), t0)
|
|
||||||
stores.workingMemory.append("c1", WorkingMemoryEntry.Assistant("m2", listOf(Content.Text("a1"))), t0)
|
|
||||||
stores.workingMemory.append("c2", WorkingMemoryEntry.User("m2", listOf(Content.Text("u2"))), t0)
|
|
||||||
stores.workingMemory.append("c2", WorkingMemoryEntry.Assistant("m3", listOf(Content.Text("a2"))), t0)
|
|
||||||
stores.workingMemory.compact(2, "c1", summaryText = "sum")
|
|
||||||
val c1 = stores.workingMemory.list("c1")
|
|
||||||
val c2 = stores.workingMemory.list("c2")
|
|
||||||
// c1: User m1 + Summary
|
|
||||||
assertEquals(2, c1.size)
|
|
||||||
assertTrue(c1[1].entry is WorkingMemoryEntry.Summary)
|
|
||||||
// c2 не тронут
|
|
||||||
assertEquals(2, c2.size)
|
|
||||||
assertTrue(c2[1].entry is WorkingMemoryEntry.Assistant)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `rename updates title and bumps updated_at`() = runTest {
|
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
|
||||||
stores.conversations.upsert(ConversationRecord("c1", null, false, t0, t0))
|
|
||||||
val newTs = stores.conversations.rename("c1", "Renamed")
|
|
||||||
assertNotNull(newTs)
|
|
||||||
assertTrue(newTs > t0)
|
|
||||||
val got = stores.conversations.get("c1")!!
|
|
||||||
assertEquals("Renamed", got.title)
|
|
||||||
assertEquals(newTs, got.updatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `rename can clear title`() = runTest {
|
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
|
||||||
stores.conversations.upsert(ConversationRecord("c1", "Title", false, t0, t0))
|
|
||||||
stores.conversations.rename("c1", null)
|
|
||||||
val got = stores.conversations.get("c1")!!
|
|
||||||
assertNull(got.title)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `delete returns false when conversation does not exist`() = runTest {
|
|
||||||
assertFalse(stores.conversations.delete("nope"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `error record roundtrip through audit log`() = runTest {
|
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
|
||||||
stores.messages.append(
|
|
||||||
MessageRecord.Error(
|
|
||||||
id = "e1",
|
|
||||||
conversationId = "c1",
|
|
||||||
message = "boom",
|
|
||||||
code = "E42",
|
|
||||||
createdAt = t0,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
stores.messages.append(
|
|
||||||
MessageRecord.Error(
|
|
||||||
id = "e2",
|
|
||||||
conversationId = "c1",
|
|
||||||
message = "no code",
|
|
||||||
code = null,
|
|
||||||
createdAt = Instant.fromEpochMilliseconds(1_700_000_001_000),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()
|
|
||||||
assertEquals(2, all.size)
|
|
||||||
val first = assertIs<MessageRecord.Error>(all[0])
|
|
||||||
assertEquals("e1", first.id)
|
|
||||||
assertEquals("boom", first.message)
|
|
||||||
assertEquals("E42", first.code)
|
|
||||||
assertEquals(t0, first.createdAt)
|
|
||||||
assertNull(assertIs<MessageRecord.Error>(all[1]).code)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `image content roundtrip through message payload`() = runTest { val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
|
||||||
val bytes = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47) // PNG header
|
|
||||||
stores.messages.append(
|
|
||||||
MessageRecord.UserMessage(
|
|
||||||
id = "m1",
|
|
||||||
conversationId = "c1",
|
|
||||||
content = listOf(Content.Image(data = bytes, mime = "image/png")),
|
|
||||||
createdAt = t0,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()
|
|
||||||
val image = (all[0] as MessageRecord.UserMessage).content[0] as Content.Image
|
|
||||||
assertEquals("image/png", image.mime)
|
|
||||||
assertTrue(bytes.contentEquals(image.data))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `user message context roundtrips through SQLite`() = runTest {
|
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
|
||||||
val ctx = MessageContext(
|
|
||||||
origin = MessageOrigin.EVENT,
|
|
||||||
description = "scheduled cron morning-briefing",
|
|
||||||
sourceId = "cron-42",
|
|
||||||
)
|
|
||||||
stores.messages.append(
|
|
||||||
MessageRecord.UserMessage(
|
|
||||||
id = "m1",
|
|
||||||
conversationId = "c1",
|
|
||||||
content = listOf(Content.Text("wake up")),
|
|
||||||
createdAt = t0,
|
|
||||||
context = ctx,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()
|
|
||||||
assertEquals(1, all.size)
|
|
||||||
val user = assertIs<MessageRecord.UserMessage>(all[0])
|
|
||||||
assertEquals(ctx, user.context)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `user message without context roundtrips with null context`() = runTest {
|
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
|
||||||
stores.messages.append(
|
|
||||||
MessageRecord.UserMessage(
|
|
||||||
id = "m1",
|
|
||||||
conversationId = "c1",
|
|
||||||
content = listOf(Content.Text("regular user message")),
|
|
||||||
createdAt = t0,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val all = stores.messages.listFlow("c1", Instant.DISTANT_PAST).toList()
|
|
||||||
val user = assertIs<MessageRecord.UserMessage>(all[0])
|
|
||||||
assertNull(user.context)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `working memory user entry context roundtrips through SQLite`() = runTest {
|
|
||||||
val t0 = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
|
||||||
val ctx = MessageContext(origin = MessageOrigin.SYSTEM, description = "agent startup")
|
|
||||||
stores.workingMemory.append(
|
|
||||||
conversationId = "c1",
|
|
||||||
entry = WorkingMemoryEntry.User(
|
|
||||||
sourceMessageId = "m1",
|
|
||||||
content = listOf(Content.Text("boot")),
|
|
||||||
context = ctx,
|
|
||||||
),
|
|
||||||
now = t0,
|
|
||||||
)
|
|
||||||
val list = stores.workingMemory.list("c1")
|
|
||||||
assertEquals(1, list.size)
|
|
||||||
val user = assertIs<WorkingMemoryEntry.User>(list[0].entry)
|
|
||||||
assertEquals(ctx, user.context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user