5 Commits

Author SHA1 Message Date
subochev 1e4c4d679f mcp+tools: preserve JSON primitive types across the wire
McpLiteToolAdapter.parseArgsJson() used JsonPrimitive.content for every value
(always String), so MCP servers saw e.g. max_length:"500" and rejected
calls with '500' is not of type 'integer'. Replaced with kotlinx.serialization
booleanOrNull/intOrNull/longOrNull/doubleOrNull ladder that mirrors the
litert-openai parser.

ChatConversation.encodeArgsJson was also still calling encodeToolArgs
(stub from earlier pass). Inlined a small Any?.toJsonElement() helper
that preserves Boolean/Number/String/Map/List types when re-serializing
the LiteToolCall.arguments map into the argsJson string fed to
tool.invoke().

E2E re-verified: litellm local/codding + mcp-server-fetch now invokes
fetch__fetch({max_length:2000,url:...}) once, MCP gets a real
'Failed to fetch robots.txt' from the actual network sandbox instead
of rejecting the schema, LLM reports the real cause to the user.
2026-09-13 17:50:41 +03:00
subochev 4c66947c25 standalone: add MCP client + tool-call loop
- Add mcp/McpConfig + McpRegistry wrapping io.modelcontextprotocol:kotlin-sdk-client 0.15.0
  - Supports stdio (uvx/npx/python) and streamable HTTP transports
  - Claude Desktop-compatible JSON config (AGENTIK_MCP_CONFIG)
  - server__tool name prefix to avoid collisions between servers
- Add agent/NamedTool (name + LiteTool pair) and tools: List<NamedTool> on ChatAgent/ChatConversation
- Implement tool-call loop in ChatConversation.runTurn:
  - delta.toolCalls -> emit ToolCall event -> persist audit -> execute tool
    -> emit ToolResult -> persist -> liteConv.addToolResult(callId, name, result)
  - separate tc-/tr- prefixes keep SQL PRIMARY KEY unique while toolCallId FK is preserved
- 8 McpConfig + 4 McpRegistry unit tests; +1 ChatAgentTest tool-loop test (44/44 total)
- e2e verified: real MCP fetch server (mcp-server-fetch) + litellm local/codding
  -> LLM calls fetch__fetch, MCP exec, result fed back, conversation continues

docs/STANDALONE.md: drop 'no tools / no MCP' from §8; replace 'Подключить тул (v2)' stub
with full in-agent + MCP recipe and tool-loop algorithm in §7
2026-09-13 15:59:58 +03:00
subochev afdfb37e35 Upgrade litert-api/litert-google/litert-openai to v7 (litertlm-jvm 0.17.0), drop workarounds 2026-09-13 14:46:09 +03:00
subochev 9e5d61707d standalone v1: dual-backend (openai + litert-google) with SQLDelight dual-log persistence
Replace EchoProtoAgent / EchoAgent / EchoA2aHandler placeholders with a real
stateful agent on top of SQLite (SQLDelight 2.3.2) and litert-api v6.

persistence (commonMain):
- ConversationStore / MessageStore / WorkingMemoryStore — three narrow
  interfaces, all operations suspend, AutoCloseable.
- MessageRecord sealed: UserMessage / AssistantMessage (Body subtype),
  ToolCall / ToolResult (audit-only), Summary / System (working-memory-only
  synthetic). Snake-case @SerialName discriminators.
- WorkingMemoryEntry sealed: System / User(sourceMessageId) /
  Assistant(sourceMessageId); sourceMessageId is null for System.
- Two-table dual-log model: append-only message audit + mutable
  working_memory with monotonic order_idx.

SQLite (jvmMain):
- SQLDelight schema + SqliteConversationStore / SqliteMessageStore /
  SqliteWorkingMemoryStore under src/jvmMain/sqldelight/.
- SqliteStores.open(path) / inMemory(); Schema.create gated on
  sqlite_master probe for idempotency.
- All payload_json is the MessageRecord encoded as JSON; subtype-specific
  fields avoid migrations.

agent (jvmMain):
- ChatAgent — stateful proto.Agent with live in-memory cache, lock-protected,
  AgentEvent bus (Created/Deleted).
- ChatConversation — long-lived LiteConversation handle; created lazily on
  first send from working_memory (system + initial messages), reused across
  all subsequent turns (REQUIRED for litert-google KV-cache).
- Per turn: append User to audit + WM → sendStreamContents (wrapped in
  transformWhile for litert-google-jvm 0.16.1 isDone workaround) → emit
  AppendText deltas → append Assistant to audit + WM + touch conversation.
- isClosed flag so getConversation reconstructs after close.

llm (jvmMain):
- LlmConfig data class with LlmBackend enum (OPENAI / GOOGLE); fromEnv
  parses AGENTIK_LLM_BACKEND and dispatches to backend-specific config.
- OpenAI: litert-openai, OpenAI-compatible endpoint, validated
  baseUrl/apiKey/model.
- Google: litert-google (reflection-resolved pw.binom.litert.google
  factory) on top of litertlm-jvm 0.16.1 native engine;
  visionBackend/audioBackend = null (LiteRT-LM 0.16.1 binds encoder
  graph even with null backend, but a model lacking encoder crashes;
  null is the correct "don't bind" signal).
- foldSystemIntoFirstUser (default true for GOOGLE) folds system prompt
  into the first user message to avoid chat template alternation issues.

build:
- Add sqldelight plugin + runtime + sqlite-driver + coroutines-extensions
  to gradle/libs.versions.toml.
- litert-openai: implementation; litert-google: runtimeOnly (resolved via
  reflection at runtime).
- KMP jvm executable via @OptIn(ExperimentalKotlinGradlePluginApi) +
  jvm { binaries { executable { mainClass.set("...MainKt") } } }.

tests (jvmTest): 30 passing
- PersistenceTest (11): conversation upsert/list/cascade-delete/rename/
  touch; message audit append/list; working-memory order preservation;
  image-content payload roundtrip.
- ChatAgentTest (14): system-prompt seeding; persistent vs temp
  persistence across SqliteStores reopen; multi-turn audit + WM growth;
  interrupt of in-flight slow send; agentEvents Created/Deleted flow;
  closed-conv reconstruct via getConversation.
- LlmConfigTest (6): env happy path, defaults, missing fields throw.

smoke tested e2e:
- openai backend against real llm.binom.pw/v1 (myopenai/local/codding)
  — multi-turn dialogue persisted, kill -9 + restart survives.
- google backend against gemma-4-E2B-it.litertlm — multi-turn
  ("Hello there!" → "2 + 2 = 4"), KV-cache survives across turns,
  SSE start→append_text*→end cleanly closes.

docs/STANDALONE.md updated for v1 architecture, dual-backend env table,
long-lived LiteConversation invariant, and litert-google-jvm 0.16.1
isDone-stream workaround.
2026-09-13 12:39:36 +03:00
subochev a3581abf84 Bring up :proto protocol + :server (Ktor) + :client (HTTP) modules; wire :server into standalone with EchoProtoAgent
Major additions:

* :proto (KMP submodule) — in-house stateful protocol replacing AG-UI.
  Agent owns conversation transcript; Conversation.events(after) is a live,
  replay-free stream; backfill via Conversation.getMessages(after, offset, limit).
  Each Event carries an Instant date for client-side resume tracking.
  Sealed hierarchies (Content/Message/Event/AgentEvent) annotated @Serializable
  with snake_case @SerialName JSON discriminators so the wire format is
  decoupled from Kotlin class names.

* :server (JVM, Ktor 3.1.3) — REST+SSE facade for Agent.
  Public entry: Route.agentikAgent(agent, path = "/agentik").
  Endpoints: create/list/get/patch/delete conversations, POST messages (202),
  POST interrupt, GET messages, GET conversation events (SSE),
  GET agent events (SSE), GET /health. Custom Instant serializer for
  kotlin.time.Instant registered contextually on agentikJson (ISO-8601,
  ignoreUnknownKeys=true, explicitNulls=false).

* :client (JVM, Ktor HTTP Client + CIO) — mirror of :server returning
  a pw.binom.agentik.proto.Agent backed by HTTP calls. Custom SSE parser
  since ktor-client-sse is not on the 3.1.3 client classpath.

* standalone — EchoProtoAgent (in-memory Agent for :proto), EchoAgent
  (existing AG-UI echo), both mounted on the same Netty embedded server
  on port 8080 (/agui and /agentik); A2A stays on its own CIO engine on
  8081. EchoProtoAgent smoke-tested end-to-end against :server: all 11
  endpoints, including live SSE delivery of StartResponse/AppendText/End
  event triplets and Agent-level Created/Deleted events.

Design notes pinned in:
* agentik/IRC-QUESTIONS.md — closed 13-item checklist for the upcoming
  :irc-server transport (channel = conversation, CTCP for structural
  events, draft/chathistory for backfill, ImageStore side-channel, etc).
* docs/ARCHITECTURE.md — overall layout snapshot.
2026-09-12 01:10:27 +03:00
59 changed files with 4880 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
# Gradle
.gradle/
build/
**/build/
# Kotlin
*.iml
.kotlin/
# IDE
.idea/
*.ipr
*.iws
out/
# OS
.DS_Store
# Local tooling (Magic Context, IDE plugins, MCP configs)
.cortexkit/
.veai/
+40
View File
@@ -0,0 +1,40 @@
# IRC-транспорт — открытые вопросы
По мере закрытия отмечаем `- N. [x]`. Закрытый вопрос остаётся в файле с принятым решением.
- 1. [x] **История.** Принято: новый абстрактный метод `suspend fun getLatestMessages(offset: Int, limit: Int): List<Message>` в `:proto.Conversation` (offset = пропустить С КОНЦА, 0 = самые свежие). IRC-сервер не держит своего буфера, на `CHATHISTORY` дёргает агента. CAP `draft/chathistory` объявляем.
- 2. [x] **Tool/Error/Image события — раскладка по IRC.** Принято. Каждый `Event` мапится:
- `StartReasoning` → дропаем с провода
- `StartResponse(TEXT|IMAGE)` → CTCP `AGENTIK response-start {"type":"text"|"image"}`
- `AppendText(body)` → `PRIVMSG #chan :body`
- `AppendImage(body, mime)` → через `ImageStore` → CTCP `AGENTIK image {"url":..,"mime":..,"ttl":..}`
- `ToolCall` → CTCP `AGENTIK tool-call {json}`
- `ToolResult` → CTCP `AGENTIK tool-result {json}`
- `Error` → CTCP `AGENTIK error {json}`
- `End` → CTCP `AGENTIK end`
- `Interrupted` → CTCP `AGENTIK interrupted`
- 3. [x] **Interrupt.** **Упрощение:** команды `/stop` и `/interrupt` в `PRIVMSG` (т.е. `PRIVMSG #chan :/stop`) вызывают `Conversation.interrupt()`. Если `PRIVMSG` приходит во время активного размышления — сервер сначала зовёт `interrupt()`, затем `send(content)`. CTCP-вариант дропаем.
- 4. [x] **`AgentEvent.Created/Deleted/Renamed` маппинг.** Принято: Created = IRC `JOIN`-бродкаст; Deleted = `KICK` самого себя; Renamed = `TOPIC #foo :new title`.
- 5. [x] **NICK агента.** Принято: параметр в DSL, дефолт `"Agent"`.
- 6. [x] **Multi-user в канале.** Принято: **в канале всегда только наш агент и наш пользователь. Других не будет никогда.**
- 7. [x] **Маппинг канал ↔ Conversation.** Принято: имя IRC-канала = `Conversation.title`; `Conversation.id` = UUID, выдаётся через `CTCP AGENTIK id #foo`; при переименовании канала id стабилен.
- 8. [x] **Создание канала.** Принято: `JOIN #foo` → создаём `Conversation(title="foo", id=<uuid>)`. Если уже есть — заходим.
- 9. [x] **Удаление канала.** Принято: `PART` закрывает сторону клиента; `CTCP AGENTIK delete #foo` — удаление Conversation-а.
- 10. [x] **Модуль.** Принято: `:irc-server`, KMP через kotlinx-io. Также модуль содержит HTTP staging-эндпоинт для картинок (см. п.13).
- 11. [x] **Аутентификация клиента.** Принято: без auth, любой может подключиться.
- 12. [x] **Capabilities (ircv3).** Принято: в первом проходе объявляем `server-time`, `message-tags`, `batch`, `draft/chathistory`. SASL не объявляем (п.11). Остальные CAPs (echo-message, labeled-response, standard-replies, multi-user stuff) добавляем инкрементально.
- 13. [x] **ImageStore.** Принято: `ImageStore` живёт в `:irc-server`, дефолтная in-memory реализация с **TTL 600 сек**, staging-порт **авто-pick** (0 → свободный). Клиент через IRC картинки **не шлёт** (для этого HTTP `:server`). Конкретную реализацию `ImageStore` пользователь сделает позже сам, в первом проходе — наша in-memory.
## Все вопросы закрыты
Итого решений по `:irc-server`:
- Модуль `:irc-server`, KMP через kotlinx-io.
- Канал IRC = `Conversation` (имя = title, UUID через CTCP `AGENTIK id`).
- В канале всегда только 1 пользователь + агент (ник `Agent` по умолчанию).
- `Event` → IRC: `PRIVMSG` для текста, CTCP `AGENTIK <имя> {json}` для всего остального. `StartReasoning` дропается.
- Interrupt через `/stop` / `/interrupt` в PRIVMSG; входящий PRIVMSG во время размышления = `interrupt()` + `send()`.
- История через `CHATHISTORY` (LATEST/BEFORE/BETWEEN/AFTER), сервер не буферизует, дёргает новый `:proto` метод `getLatestMessages(offset, limit)`.
- Картинки только agent → client через `ImageStore` + HTTP staging в том же модуле.
- CAPs: `server-time`, `message-tags`, `batch`, `draft/chathistory`. Без auth.
Можно кодить.
+8
View File
@@ -0,0 +1,8 @@
plugins {
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false
}
group = "pw.binom.agentik"
version = "0.1.0"
+24
View File
@@ -0,0 +1,24 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
dependencies {
implementation(project(":proto"))
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
}
@@ -0,0 +1,78 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentEvent
import pw.binom.agentik.proto.Conversation
import kotlin.time.Instant
/**
* HTTP-реализация [Agent]. Ходит в `:server`-фасад, см. `agentikAgent(...)`.
*
* Замечание по [createConversation]: интерфейс [Agent] объявлен не-suspend
* (in-process кейс этого не требует), но HTTP-вариант обязан ждать ответа
* POST `/conversations`. Используем `runBlocking` — это одноразовая
* операция (открытие чата), не горячий путь. В UI-контексте вызывающий сам
* решает, что делать.
*/
internal class AgentClient(
private val httpClient: HttpClient,
private val baseUrl: String,
override val id: String,
) : Agent {
private val agentUrl: String = baseUrl.trimEnd('/')
override fun createConversation(temp: Boolean): Conversation =
runBlocking {
val snapshot: ConversationSnapshot = httpClient.post("$agentUrl/conversations") {
contentType(ContentType.Application.Json)
setBody(RequestCreateConversation(temp))
}.body()
ConversationClient(httpClient = httpClient, baseUrl = agentUrl, snapshot = snapshot)
}
override suspend fun getConversation(id: String): Conversation? {
val response = httpClient.get("$agentUrl/conversations/$id")
if (response.status == HttpStatusCode.NotFound) return null
val snapshot = response.body<ConversationSnapshot>()
return ConversationClient(httpClient, agentUrl, snapshot)
}
override suspend fun deleteConversation(id: String): Boolean {
val response = httpClient.delete("$agentUrl/conversations/$id")
return response.status == HttpStatusCode.NoContent
}
override suspend fun getConversations(offset: Int, limit: Int): List<Conversation> {
val snapshots = httpClient.get("$agentUrl/conversations") {
parameter("offset", offset)
parameter("limit", limit)
}.body<List<ConversationSnapshot>>()
return snapshots.map { ConversationClient(httpClient, agentUrl, it) }
}
override fun events(after: Instant): Flow<AgentEvent> = flow {
val response = httpClient.get("$agentUrl/events?after=$after")
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(AgentEvent.serializer(), payload))
}
}
}
@@ -0,0 +1,43 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import pw.binom.agentik.proto.Agent
/**
* Создаёт [Agent], который под капотом ходит в HTTP-фасад `agentikAgent`
* (модуль `:server`).
*
* ```
* val client = AgentikAgent(
* id = "my-agent",
* baseUrl = "http://localhost:8080/agentik",
* )
* val conv = client.createConversation(temp = false)
* conv.send(listOf(Content.Text("hi")))
* conv.events(Instant.DISTANT_PAST).collect { ev -> ... }
* ```
*
* [id] пробрасывается в реализацию [Agent.id] — сервер про идентичность
* агента не знает, поэтому клиент должен её знать сам (или взять из
* конфига).
*
* [httpClient] по умолчанию — [defaultAgentikHttpClient] (CIO + JSON +
* SSE). Можно передать свой, если нужен свой engine/логирование/аутентификация.
*/
fun AgentikAgent(
id: String,
baseUrl: String,
httpClient: HttpClient = defaultAgentikHttpClient(),
): Agent = AgentClient(httpClient = httpClient, baseUrl = baseUrl, id = id)
/**
* Дефолтный [HttpClient] для общения с `agentikAgent`: CIO-движок и
* kotlinx-serialization с тем же wire-форматом, что на сервере. SSE-парсер
* (см. [readSse]) живёт в общем коде и плагина не требует.
*/
fun defaultAgentikHttpClient(): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) { json(agentikJson) }
}
@@ -0,0 +1,94 @@
package pw.binom.agentik.client
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Event
import pw.binom.agentik.proto.Message
import kotlin.time.Instant
/**
* HTTP-реализация [Conversation]. Ходит в `:server`-фасад под
* `/conversations/{id}/...`.
*
* `id` отдаётся синхронно (он в [snapshot], доступном сразу). Остальные
* поля (`title`, `isSupportImageInput`, ...) — тоже из snapshot. [snapshot]
* обновляется после [rename] (сервер возвращает свежий).
*
* **Caveat — `updatedAt`:** сервер бампит `updatedAt` на каждый
* `send`/`rename`, но клиент узнает об этом только при следующем
* `rename` или `getConversation`. Если нужна свежая свежесть после
* `send` — перезапроси через `Agent.getConversation(id)`.
*
* [close] — локальный no-op: сервер держит диалог живым. Удалить —
* через `Agent.deleteConversation(id)`.
*/
internal class ConversationClient(
private val httpClient: HttpClient,
private val baseUrl: String,
private var snapshot: ConversationSnapshot,
) : Conversation {
override val id: String get() = snapshot.id
override val title: String? get() = snapshot.title
override val isSupportImageInput: Boolean get() = snapshot.isSupportImageInput
override val isSupportImageOutput: Boolean get() = snapshot.isSupportImageOutput
override val isTemporal: Boolean get() = snapshot.isTemporal
override val updatedAt: Instant get() = snapshot.updatedAt
private val convUrl: String get() = "$baseUrl/conversations/$id"
override suspend fun rename(title: String) {
val updated = httpClient.patch(convUrl) {
contentType(ContentType.Application.Json)
setBody(RequestRename(title))
}.body<ConversationSnapshot>()
snapshot = updated
}
override suspend fun send(content: List<Content>) {
httpClient.post("$convUrl/messages") {
contentType(ContentType.Application.Json)
setBody(content)
}
}
override suspend fun interrupt() {
httpClient.post("$convUrl/interrupt")
}
override fun events(after: Instant): Flow<Event> = flow {
val response = httpClient.get("$convUrl/events?after=$after")
check(response.status == HttpStatusCode.OK) {
"events: server returned ${response.status}"
}
readSse(response.bodyAsChannel())
.collect { payload ->
emit(agentikJson.decodeFromString(Event.serializer(), payload))
}
}
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message> =
httpClient.get("$convUrl/messages") {
parameter("after", after.toString())
parameter("offset", offset)
parameter("limit", limit)
}.body()
override fun close() {
// Локальный no-op: диалог на сервере живёт, пока не вызван
// Agent.deleteConversation(id). См. [Conversation.close] KDoc.
}
}
@@ -0,0 +1,26 @@
package pw.binom.agentik.client
import kotlinx.serialization.Serializable
import kotlin.time.Instant
/**
* HTTP-снимок [pw.binom.agentik.proto.Conversation] — те же поля, что у
* интерфейса, но без методов. Зеркалит
* [pw.binom.agentik.server.ConversationSnapshot]. Дубликат сознательно:
* переедем в общий `:wire`, когда появится больше типов.
*/
@Serializable
data class ConversationSnapshot(
val id: String,
val isSupportImageInput: Boolean,
val isSupportImageOutput: Boolean,
val isTemporal: Boolean,
val title: String? = null,
val updatedAt: Instant,
)
@Serializable
internal data class RequestCreateConversation(val temp: Boolean)
@Serializable
internal data class RequestRename(val title: String)
@@ -0,0 +1,38 @@
package pw.binom.agentik.client
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlin.time.Instant
/**
* Зеркалит [pw.binom.agentik.server.InstantSerializer]. Дублируем сознательно:
* wire-формат компактный, альтернатива — отдельный `:wire`-модуль ради 10 строк.
*/
internal object InstantSerializer : KSerializer<Instant> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("kotlin.time.Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) =
encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Instant =
Instant.parse(decoder.decodeString())
}
/**
* JSON-конфиг клиента. Должен **точно** совпадать с серверным `agentikJson` —
* один и тот же wire-формат с обеих сторон.
*/
internal val agentikJson: Json = Json {
ignoreUnknownKeys = true
explicitNulls = false
serializersModule = SerializersModule {
contextual(Instant::class, InstantSerializer)
}
}
@@ -0,0 +1,45 @@
package pw.binom.agentik.client
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.readUTF8Line
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
/**
* Минимальный парсер Server-Sent Events, читающий канал до EOF и эмиттящий
* собранный `data:`-пейлоад каждого события. Достаточно для нашего wire-формата:
* сервер шлёт `data: <json>\n\n`, имя события и прочие поля не используются.
*
* Формат (см. WHATWG):
* event: foo — игнор (у нас нет имён событий)
* data: {"k":1} — накапливается, многострочный `data:` склеивается через '\n'
* :comment — игнор
* id:/retry:/<blank> — пустая строка = граница события; всё остальное игнор
*
* Поток закрывается, когда канал доходит до EOF; накопленный `data` (если есть)
* эмитится как финальный ивент.
*/
internal fun readSse(channel: ByteReadChannel): Flow<String> = flow {
val data = StringBuilder()
while (!channel.isClosedForRead) {
val line = channel.readUTF8Line() ?: break
when {
line.isEmpty() -> {
if (data.isNotEmpty()) {
emit(data.toString())
data.clear()
}
}
line.startsWith("data: ") -> {
if (data.isNotEmpty()) data.append('\n')
data.append(line.removePrefix("data: "))
}
line.startsWith("data:") -> {
if (data.isNotEmpty()) data.append('\n')
data.append(line.removePrefix("data:"))
}
// event:, id:, retry:, ":" (comment) — игнорируем
}
}
if (data.isNotEmpty()) emit(data.toString())
}
+113
View File
@@ -0,0 +1,113 @@
# Agentik — архитектура (на основе ограничений AGUI)
## 1. Контекст
agentik — runtime агента. Внешне он exposes два фасада:
- **AG-UI** — SSE-поток событий для UI-клиентов (`POST /agui`), транспорт Netty.
- **A2A** — JSON-RPC для agent↔agent (`POST /`, `message/send`), транспорт CIO.
Ядро (сессии, чат-цикл, тулы, LLM, MCP) **не зависит от транспорта**: фасады лишь сериализуют/десериализуют его. Библиотеки из каталога `caffeine`: `agui` 0.1.0, `a2a` 1.0.0-SNAPSHOT; LLM — `pw.binom.openai` (ktor-impl).
## 2. Ограничения AGUI (что диктует протокол)
AGUI — push-протокол. Агент = `Agent { RunAgentInput -> Flow<BaseEvent> }`.
Ключевые типы (из `pw.binom.agui.api`):
| Тип | Смысл |
|---|---|
| `RunAgentInput { threadId, runId, state, messages, tools, context, forwardedProps }` | весь ввод хода. Клиент шлёт всё, сервер по умолчанию stateless. |
| `threadId` | **сессия/конверсация**. |
| `runId` | **один ход** в рамках сессии. |
| `Message { id, role: developer\|system\|assistant\|user\|tool, content, toolCalls?, toolCallId? }` | сообщение. У assistant — `toolCalls`; у tool — `toolCallId`. |
| `Tool { name, description, parameters: JSONSchema }` | тул (JSON Schema в `parameters`). |
| `State = Map<String, JsonElement>` | нестрогий state хода (snapshot/delta). |
| `Context { description, value }` | кусок контекста. |
События (`BaseEvent`, дискриминатор `type`):
- lifecycle: `RUN_STARTED{threadId,runId}` → … → `RUN_FINISHED` / `RUN_ERROR{message,code}`
- steps: `STEP_STARTED/FINISHED{stepName}`
- text: `TEXT_MESSAGE_START{messageId,role}` → `TEXT_MESSAGE_CONTENT{messageId,delta}` → `TEXT_MESSAGE_END{messageId}`
- tool: `TOOL_CALL_START{toolCallId,toolCallName,parentMessageId?}` → `TOOL_CALL_ARGS{toolCallId,delta}` → `TOOL_CALL_END{toolCallId}` → `TOOL_CALL_RESULT{toolCallId,content}`
- state: `STATE_SNAPSHOT{snapshot}` / `STATE_DELTA{delta}` / `MESSAGES_SNAPSHOT{messages}`
- прочее: `RAW{event}` / `CUSTOM{name,value}`
Свободные варианты на стороне либы:
- stateless: `Agent { input -> flow }` (использует `input.messages/state/tools`).
- stateful: `AbstractAgent(agentId, threadId)` — держит `state` + `messages`, переопределяешь `runAgent(RunAgentParameters)`.
Сервер (`aguiAgent(agent, path="/agui")`) не хранит ничего между запросами: декодирует `RunAgentInput`, вызывает `agent.run`, стримит события, оборачивает сбой в `RUN_ERROR`.
## 3. Как вписываемся (принципы)
1. **`threadId` = наша сессия.** Держим `SessionStore[threadId]` на сервере даже при stateless-сервере AGUI: на каждом ходе реконсилируем `input.messages` с сохранёнными и дописываем новые. Это даёт «настоящие сессии» с историей.
2. **`runId` = ход.** Один `RunAgentInput` → один цикл ядра → один поток событий.
3. **Ядро говорит на AGUI-событиях** — это и есть наш протокол вывода; фасады не переопределяют его.
4. **LLM — за интерфейсом.** Ядро не знает, какая LLM. Точка входа `LlmClient.complete(...)`.
5. **Тулы: внутренний реестр + MCP.** `ToolRegistry` объединяет «родные» тулы и тулы MCP-серверов (через bridge). LLM вызывает их; ядро исполняет и возвращает `TOOL_CALL_RESULT`.
6. **А2A = request/response.** Переиспользуем то же ядро, но собираем финальный текст и отдаём одним A2A `Message`.
## 4. Пакеты / модули
```
pw.binom.agentik
├─ core
│ ├─ session Session(threadId, messages, state) + SessionStore (in-memory, by threadId)
│ ├─ tool ToolSpec, Tool, ToolRegistry (InMemoryToolRegistry, builtin tools)
│ ├─ llm LlmClient (interface) + LlmMessage/LlmToolCall/LlmResponse + EchoLlmClient (stub)
│ ├─ mcp McpClient (interface: listTools/callTool) + McpTool (bridge) + ToolRegistry.registerMcpClient
│ └─ engine AgentEngine: prompt -> LLM -> tool-loop -> AGUI events (RunStarted..RunFinished)
└─ fronts
├─ agui AgentikAgent : Agent { input -> engine.run(input) } [вместо EchoAgent]
└─ a2a EngineA2aHandler : A2A handle(msg, contextId) -> Message [собирает финальный текст]
```
Wiring (в `standalone`): `Main` собирает `AgentEngine(llm, tools+MCP, sessions)`, отдаёт его `AgentikAgent` (AGUI) и `EngineA2aHandler` (A2A). Конфигурация (LLM, MCP-серверы, тулы) — в одном месте (см. §8).
## 5. Поток данных (AGUI)
```
client ──POST /agui {RunAgentInput}──> AgentikAgent
│ engine.run(input)
▼
SessionStore.getOrCreate(threadId)
merge input.messages, input.tools
│
┌─> llm.complete(system, history, tools, state)
│ └─ toolCalls? -> TOOL_CALL_* + ToolRegistry.call + TOOL_CALL_RESULT -> llm.complete (loop)
└─> text? -> TEXT_MESSAGE_* (последнее)
│
persist final assistant msg in session
│
client <──SSE [BaseEvent]──────┘ (RunStarted .. RunFinished / RunError)
```
## 6. A2A-фасад (request/response)
`A2A handle(request: Message, contextId): Message`:
1. `text = request.parts.filterIsInstance<TextPart>().join("")`.
2. `RunAgentInput(threadId = contextId ?: new, runId = randomRunId(), messages = [Message(USER, text)])`.
3. `events = engine.run(input)` (синхронно, `runBlocking { ...toList() }`).
4. `answer = events.filterIsInstance<TextMessageContentEvent>().join { it.delta }`.
5. вернуть `Message(role=AGENT, parts=[TextPart(answer)])`.
`contextId` ↔ `threadId` — общая сессия. A2A не стримит, поэтому события ядра сворачиваются в финальный ответ.
## 7. Зоны ответственности
**Ядро (скелет уже закладываем):**
`Session/SessionStore`, `Tool/ToolRegistry`, `LlmClient`(iface)+stub, `McpClient`(iface)+bridge, `AgentEngine`, `AgentikAgent`, `EngineA2aHandler`, `Main`.
**Ты (поверх ядра):**
- реализация `LlmClient` (через `pw.binom.openai:ktor-impl`),
- конкретные «родные» тулы,
- транспорт `McpClient` (подключение к MCP-серверам).
## 8. Открытые решения (нужны решения)
1. **Персист сессий** — in-memory или на диск/БД (сейчас in-memory).
2. **Конфигурация** — MCP-серверы, LLM endpoint/ключ, системный промпт: env / yaml / файл (сейчас env-заготовки: `AGENTIK_A2A_AGENTS`).
3. **Ограничения** — `maxToolRounds`, таймауты LLM/тулов, лимиты размера.
4. **Аутентификация** — Bearer на обоих фасадах (а2a/agui принимают `token`), пока не включена.
5. **Структура модулей** — держать всё в `standalone` (по пакетам) или вынести `core`/`mcp` в отдельные подмодули (для переиспользования).
6. **Поведение сессии** — «клиент источник правды» (replace) vs «сервер источник правды» (append) при реконсилии `messages`.
```
+314
View File
@@ -0,0 +1,314 @@
# Standalone — рантайм агента agentik
`standalone` — это исполняемое JVM-приложение (точка входа `pw.binom.agentik.standalone.MainKt`), которое поднимает реальный агент `ChatAgent` (stateful, SQLite-персистентный) и навешивает на него HTTP+SSE фасад `:server`. LLM-движок выбирается через `AGENTIK_LLM_BACKEND` — на v1 поддерживаются `litert-openai` (любой OpenAI-совместимый endpoint) и `litert-google` (on-device движок LiteRT-LM 0.17.0 через нативную `.so`-библиотеку из litertlm-jvm 0.17.0).
Этот документ описывает, как `standalone` собран и как его расширять.
---
## 1. Что в коробке после `git clone`
```
:proto — единое ядро протокола (KMP, commonMain)
Agent / Conversation / Event / Message / Content / AgentEvent
stateful: агент сам хранит историю и working memory
:server — HTTP+SSE фасад :proto
public Route.agentikAgent(agent, path = "/agentik")
:standalone — JVM-рантайм с реальным LLM-агентом
ChatAgent + ChatConversation поверх SQLite и litert-* (openai/google)
default 8080:
GET /health health check
POST /agentik/conversations создать диалог (201)
GET /agentik/conversations список
GET /agentik/conversations/{id} один диалог
PATCH /agentik/conversations/{id} переименовать
DELETE /agentik/conversations/{id} удалить (204)
POST /agentik/conversations/{id}/messages отправить user-сообщение (202)
POST /agentik/conversations/{id}/interrupt прервать текущий ход (202)
GET /agentik/conversations/{id}/messages страница истории
GET /agentik/conversations/{id}/events SSE live-события хода
GET /agentik/events SSE live-события агента
```
Полная таблица эндпоинтов — в `docs/ARCHITECTURE.md` (раздел «:server»).
---
## 2. Архитектура слоёв
```
клиенты транспорт
┌───────────────┐ ┌─────────────────────────────┐
│ Web / CLI / │ ──HTTP──► │ Route.agentikAgent(agent) │
│ desktop │ ──SSE───► │ :server (Ktor + Netty) │
│ │ └──────────────┬──────────────┘
└───────────────┘ │
▼
pw.binom.agentik.proto.Agent
(ChatAgent)
│
┌───────────────┴───────────────┐
▼ ▼
ChatConversation.send(content) agent.events / agent.getConversations
│
▼
┌──────────────────────────────────────┐
│ 1. audit: append UserMessage │
│ 2. working_memory: append User │
│ 3. ensureLiteConversation: │
│ first turn → create from WM; │
│ next turns → reuse (KV-cache) │
│ 4. sendStreamContents → emit │
│ StartResponse / AppendText / │
│ End │
│ 5. audit + WM: append AssistantMessage│
└──────────────────────────────────────┘
│ │
▼ ▼
Conversation.events(after) Conversation.getMessages(after)
(live, no replay) (история)
```
Слои рантайма:
```
:standalone
├── persistence/ ← интерфейсы и records (commonMain, без зависимостей)
│ ConversationStore / MessageStore / WorkingMemoryStore
│ ConversationRecord / MessageRecord / WorkingMemoryEntry / Content
│ Payload.kt — JSON-сериализация
├── persistence/sqlite/ ← JVM: SQLDelight-схема + три SQLite-реализации
│ SqliteStores.open(path | inMemory)
│ src/jvmMain/sqldelight/.../*.sq
├── llm/ ← LlmConfig (env → OpenAI/Google), backend-agnostic
└── agent/ ← ChatAgent + ChatConversation (stateful, long-lived LiteConv)
```
Ключевой инвариант: **разговор живёт внутри агента, а не в клиенте и не в транспорте.** Транспорт — лишь сериализатор: HTTP пишет в/читает из `:server`-эндпоинтов, SSE шлёт события. У них нет своего состояния диалога.
---
## 3. Точка входа: `pw.binom.agentik.standalone.MainKt`
```kotlin
fun main() {
val port = System.getenv("AGENTIK_PORT")?.toIntOrNull() ?: 8080
val dbPath = System.getenv("AGENTIK_DB_PATH")?.takeIf { it.isNotBlank() } ?: "./agentik.db"
val llmConfig = LlmConfig.fromEnv()
val llm = llmConfig.createLlm()
val stores = SqliteStores.open(dbPath = dbPath)
val agent = ChatAgent(
id = "agentik",
stores = stores,
llm = llm,
llmConfig = llmConfig,
)
val server = embeddedServer(Netty, port = port) {
routing {
get("/health") { call.respondText("ok") }
agentikAgent(agent, path = "/agentik")
}
}
Runtime.getRuntime().addShutdownHook(Thread {
agent.close(); stores.close(); llm.close()
})
server.start(wait = true)
}
```
Один `ChatAgent` отвечает и за диалоги (`/agentik/conversations/...`), и за live-события (`/agentik/events`). Все три ресурса — БД, LLM, Netty — корректно закрываются в shutdown-хуке.
---
## 4. Контракт `Agent` (от `pw.binom.agentik.proto`)
Реализация **обязана** уметь:
| метод | смысл |
|---|---|
| `id: String` | идентификатор агента |
| `createConversation(temp: Boolean): Conversation` | новая сессия, `temp=true` — не персистить |
| `getConversation(id): Conversation?` | достать по id, `null` если нет |
| `deleteConversation(id): Boolean` | удалить |
| `getConversations(offset, limit)` | страница списка |
| `events(after: Instant): Flow<AgentEvent>` | live-события по множеству разговоров |
Реализация `Conversation`:
| метод | смысл |
|---|---|
| `id: String` | идентификатор диалога |
| `title: String?` | заголовок (может быть `null`) |
| `isTemporal: Boolean` | `true` = не персистить (`temp=true` при создании) |
| `isSupportImageInput/Output: Boolean` | мультимодальные возможности (для v1 оба `false`) |
| `updatedAt: Instant` | последний `send`/`rename` |
| `send(content: List<Content>)` | **fire-and-forget**: добавить user-сообщение, запустить ход, выйти |
| `interrupt()` | остановить текущий ход (best-effort) |
| `events(after): Flow<Event>` | live-события хода (StartReasoning, StartResponse, AppendText, End, Interrupted, Error) |
| `getMessages(after, offset, limit)` | страница истории |
| `rename(title)` | переименовать |
| `close()` | освободить ресурсы |
Главное: `send` ничего не возвращает. Чтобы получить события, нужно **отдельно** подписаться на `events(after)` ДО `send` либо сразу после — поток событий стартует с момента подписки, бэкфилл через `getMessages`.
---
## 5. Persistence — dual-log
`ChatAgent` хранит каждую сессию в двух логически разных таблицах:
| таблица | назначение | мутации |
|---|---|---|
| `message` | append-only audit log. Все user/assistant/tool-call/tool-result сообщения. Никогда не редактируется (кроме каскадного `DELETE` при удалении диалога). | только `INSERT` |
| `working_memory` | mutable LLM-контекст. System-prompt + текущая история + (в v2) суммаризации. | `INSERT`, `compact(dropFromIdx, summary)` |
Маппинг `:proto.Message ↔ MessageRecord` живёт в `ChatConversation.kt` (`toProto`/`toStorage`) — сами `MessageRecord` намеренно НЕ зависят от `:proto`, чтобы можно было сменить транспорт без миграции таблиц.
Подробный контракт — в комментариях к `MessageRecord.kt` и `WorkingMemoryEntry.kt`.
### `MessageStore`
```kotlin
suspend fun append(record: MessageRecord)
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
suspend fun listAll(conversationId: String): List<MessageRecord>
```
### `WorkingMemoryStore`
```kotlin
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
suspend fun list(conversationId: String): List<WorkingMemoryRow>
suspend fun clear(conversationId: String)
suspend fun compact(dropFromOrderIdx: Long, conversationId: String): Long
```
`compact` — атомарный «выбросить всё от `dropFromOrderIdx` и дальше, вставить новую синтетическую запись на следующий `order_idx`». Для v1 — просто `DELETE` от индекса (суммаризация появится в v2 вместе с LLM-вызовом для генерации текста).
### `ConversationStore`
```kotlin
suspend fun upsert(record: ConversationRecord)
suspend fun get(id: String): ConversationRecord?
suspend fun delete(id: String): Boolean // каскадно чистит message + working_memory
suspend fun list(offset: Int, limit: Int): List<ConversationRecord>
suspend fun rename(id: String, title: String?): Instant?
suspend fun touch(id: String, now: Instant)
```
Все три store — `AutoCloseable`; корневой ресурс `SqliteStores` закрывает их вместе с `SqlDriver`.
---
## 6. LLM-конфигурация (`LlmConfig`)
`LlmConfig.fromEnv()` парсит env, валидирует обязательные поля и выбирает бэкенд через `AGENTIK_LLM_BACKEND`:
- `openai` (default) — `litert-openai`, текст-онли чат против любого OpenAI-совместимого endpoint.
- `google` — `litert-google` (LiteRT-LM 0.17.0, on-device `.task`/`.litertlm` модель, требует нативной библиотеки через `litertlm-jvm`).
### Общие env
| env | смысл | default |
|---|---|---|
| `AGENTIK_PORT` | порт Netty (`/agentik`, `/health`) | `8080` |
| `AGENTIK_DB_PATH` | путь к SQLite-файлу | `./agentik.db` |
| `AGENTIK_LLM_BACKEND` | `openai` или `google` | `openai` |
| `AGENTIK_SYSTEM_PROMPT` | текст системного промпта | «Ты полезный ассистент. Отвечай кратко и по делу.» |
| `AGENTIK_MCP_CONFIG` | путь к `mcp.json` в формате Claude Desktop (`{"mcpServers":{"name":{"command":"...","args":[...]}` или `"url":"..."}`) | не задан (MCP выключен) |
### Backend `openai`
| env | смысл |
|---|---|
| `OPENAI_BASE_URL` | endpoint (например, `https://api.openai.com/v1` или `http://localhost:11434/v1`) — обязательно |
| `OPENAI_API_KEY` | ключ модели — обязательно |
| `OPENAI_MODEL` | имя модели (например, `gpt-4o-mini`, `myopenai/local/codding`) — обязательно |
### Backend `google` (on-device LiteRT-LM)
| env | смысл | default |
|---|---|---|
| `AGENTIK_GOOGLE_MODEL_PATH` | путь к `.task` или `.litertlm` модели — обязательно |
| `AGENTIK_GOOGLE_CACHE_DIR` | каталог кеша скомпилированных graph'ов | пусто (системный tmp) |
| `AGENTIK_GOOGLE_THREADS` | число CPU-потоков для движка | `4` |
`AGENTIK_DB_PATH=:memory:` создаёт in-memory БД (только для тестов и интеграционных проверок).
### Long-lived LiteConversation — ОБЯЗАТЕЛЬНО для обоих бэкендов
`LiteConversation` от любого litert-бэкенда — это **долгоживущая stateful ручка**: она держит историю сообщений и (для google) KV-cache/sampler-state. `ChatConversation` создаёт `LiteConversation` один раз (на первом `send`) и переиспользует на всех последующих turn'ах той же беседы. Пересоздание LiteConversation на каждый send ломает KV-cache для google (LiteRT-LM 0.17.0 умеет правильно восстанавливать state при `systemInstruction + пары User/Assistant` в `initialMessages`).
`LiteLlm.capabilities: LiteCapabilities?` (litert-api 7+) — `litert-google` читает из заголовка on-disk модели (text/vision/audio, supportsThinking, supportsFunctionCalling, maxVisionTokenBudget); `litert-openai` возвращает `null` (модель не хранится на диске). Используй для фильтрации модальностей в клиенте.
---
## 7. Расширение
### Подключить тулы (MCP / in-agent)
```kotlin
// 1) In-agent tool (нативный LiteTool):
val echoTool = object : LiteTool {
override fun describe(): String =
"""{"type":"object","properties":{"x":{"type":"string"}},"required":["x"]}"""
override fun invoke(args: String): String = "echoed: $args"
}
val tools = listOf(NamedTool("echo", echoTool))
// 2) MCP (stdio / streamable HTTP) — конфиг в Claude Desktop-формате:
val mcp = McpConfig.fromEnv() // читает AGENTIK_MCP_CONFIG=path/to/mcp.json
val registry = McpRegistry.fromConfig(mcp) // стартует все серверы, лист LiteTool'ов
val tools = registry.namedTools // server__tool префикс автоматически
// 3) В обоих случаях:
val agent = ChatAgent(id, stores, llm, llmConfig, tools = tools)
```
`LiteConversation` принимает `tools = ...` в `LiteConversationConfig`. На каждый `delta.toolCalls` из модели `ChatConversation.runTurn`:
1. Эмитит `Event.ToolCall(callId, toolName, argsJson)` клиенту (по SSE)
2. Записывает `MessageRecord.ToolCall` в audit + working memory (если не temp)
3. Вызывает `tool.invoke(argsJson)`
4. Эмитит `Event.ToolResult(resultId, resultText)`
5. Записывает `MessageRecord.ToolResult` (с `toolCallId = callId`)
6. Кормит `liteConv.addToolResult(callId, name, result)` в LiteConversation (KV-cache выживает между итерациями)
7. Цикл повторяется до `delta.toolCalls.isEmpty()`
ID у `ToolCall` и `ToolResult` разные (`tc-…` / `tr-…`), но `MessageRecord.ToolResult.toolCallId` указывает на `MessageRecord.ToolCall.id` той же логической пары. Этим достигается уникальность PK в таблице `message`.
### Добавить ещё один транспорт
Каждый транспорт — отдельный модуль, который получает `Agent` и сериализует его под свой протокол:
* `:server` (HTTP+SSE) — готов, `Route.agentikAgent(agent, path = "/agentik")`
* `:client` (HTTP-клиент) — готов, `AgentikAgent(id, baseUrl, httpClient)`
* `:irc-server` — IRC-фасад, в планах
Транспорт **не имеет доступа к внутренностям `ChatAgent`** — он видит только интерфейс `Agent`. Это и есть «транспортно-агностичное ядро».
### Добавить ещё один LLM-бэкенд
1. Описать `Config` data class с нужными полями.
2. Реализовать `LiteLlm`/`LiteConversation` поверх движка (см. litert-kmp — там уже есть `litert-google`, `litert-openai`, `litert-koog`).
3. Расширить `LlmConfig.createLlm()` веткой `when`.
### Заменить SQLite на Postgres / MongoDB / etc
1. Реализовать три store-интерфейса поверх нового движка.
2. Передать их в `ChatAgent` вместо `SqliteStores`.
3. Удалить (или оставить за `:standalone`-флагом) `:persistence/sqlite/`.
---
## 8. Что НЕ делает `standalone` сегодня
* **Нет суммаризации.** `WorkingMemoryStore.compact` уже есть, но без LLM-вызова для генерации текста суммаризации.
* **Нет авторизации.** Все эндпоинты открыты.
* **Нет инкрементальной догрузки старых сообщений.** `getMessages(after)` работает с offset/limit, но без «схлопывания» (compaction в визуальной истории — задача клиента).
Каждый пункт закрывается отдельным коммитом; код логически разделён по слоям так, чтобы точечные изменения не требовали переделки соседей.
+60
View File
@@ -0,0 +1,60 @@
[versions]
kotlin = "2.4.20"
kotlinx-serialization = "1.11.0"
kotlinx-coroutines = "1.11.0"
kotlinx-datetime = "0.8.0"
ktor = "3.1.3"
agui = "0.1.0"
a2a = "1.0.0-SNAPSHOT"
litert = "7"
sqldelight = "2.3.2"
[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
[libraries]
# --- AG-UI (pw.binom.agui) — KMP: jvm + native ---
agui-api = { module = "pw.binom.agui:api", version.ref = "agui" }
agui-client = { module = "pw.binom.agui:client", version.ref = "agui" }
agui-server = { module = "pw.binom.agui:server", version.ref = "agui" }
# --- A2A (pw.binom.a2a) — shared: KMP (jvm + linuxX64); client/server: JVM-only ---
a2a-shared = { module = "pw.binom.a2a:shared", version.ref = "a2a" }
a2a-client = { module = "pw.binom.a2a:client", version.ref = "a2a" }
a2a-server = { module = "pw.binom.a2a:server", version.ref = "a2a" }
# --- litert-kmp (pw.binom.litert) — universal LLM wrapper ---
litert-api = { module = "pw.binom.litert:litert-api", version.ref = "litert" }
litert-openai = { module = "pw.binom.litert:litert-openai-jvm", version.ref = "litert" }
litert-google = { module = "pw.binom.litert:litert-google", version.ref = "litert" }
# --- SQLDelight (app.cash.sqldelight) — KMP SQLite, JDBC driver ---
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
sqldelight-sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
# --- Ktor (сервер, JVM) ---
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-server-sse = { module = "io.ktor:ktor-server-sse", version.ref = "ktor" }
ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
ktor-client-sse = { module = "io.ktor:ktor-client-sse", version.ref = "ktor" }
# --- Model Context Protocol (MCP) ---
mcp-sdk-client = { module = "io.modelcontextprotocol:kotlin-sdk-client", version = "0.15.0" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
# --- commons ---
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+249
View File
@@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+92
View File
@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+30
View File
@@ -0,0 +1,30 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvmToolchain(21)
// "Все возможные цели сборки": jvm + весь натив. Зеркалит набор AG-UI api.
jvm()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
linuxX64()
linuxArm64()
mingwX64()
sourceSets {
commonMain.dependencies {
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.datetime)
api(libs.kotlinx.serialization.core)
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
@@ -0,0 +1,56 @@
package pw.binom.agentik.proto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.time.Instant
/**
* Ядро собственного протокола agentik (замена AG-UI). Явно stateful.
*
* Транспортно-агностично. [Agent] — фабрика stateful-диалогов:
* [createConversation] возвращает [Conversation], который сам хранит историю
* и которому отправляют ходы через [Conversation.send].
*/
public interface Agent {
/** Идентификатор агента. */
val id: String
/** Создаёт новый stateful-диалог с агентом. */
fun createConversation(temp: Boolean): Conversation
/** Диалог по идентификатору; `null`, если не найден. */
suspend fun getConversation(id: String): Conversation?
/** Удаляет диалог. Возвращает `true`, если диалог существовал и удалён. */
suspend fun deleteConversation(id: String): Boolean
/** Страница диалогов: не более [limit] штук, начиная с [offset]-го. */
suspend fun getConversations(offset: Int, limit: Int): List<Conversation>
/** Все диалоги, начиная с [offset], как поток: подгружает по [PAGE_SIZE] за раз. */
fun getConversations(offset: Int = 0): Flow<Conversation> = flow {
var skip = offset
while (true) {
val page = getConversations(skip, PAGE_SIZE)
if (page.isEmpty()) break
page.forEach { emit(it) }
skip += page.size
}
}
/**
* Live-подписка на изменения в множестве диалогов агента: создание,
* удаление, переименование (см. [AgentEvent]). События внутри конкретного
* диалога приходят через [Conversation.events].
*
* **Не реплеит** прошлое — для снимка множества используй [getConversations]
* или [getConversation].
*/
fun events(after: Instant): Flow<AgentEvent>
companion object {
const val PAGE_SIZE: Int = 100
}
}
@@ -0,0 +1,43 @@
package pw.binom.agentik.proto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Instant
/**
* Live-события уровня [Agent]: изменения в множестве диалогов
* (создание, удаление, переименование). События, происходящие **внутри**
* конкретного диалога, приходят через [Conversation.events], а не сюда.
*
* Каждое событие несёт [date] — момент эмиссии в UTC. Семантика подписки
* идентична [Conversation.events]: поток **не реплеит** прошлое, для бэкфилла
* используются `getConversations`/`getConversation`.
*/
@Serializable
sealed interface AgentEvent {
/** Момент эмиссии события в UTC. */
val date: Instant
/**
* Создан новый диалог. Передаётся его id — handle можно получить через
* [Agent.getConversation]. Подписчик после [Created] может сразу открыть
* live-подписку на этот диалог через [Conversation.events].
*/
@Serializable
@SerialName("created")
data class Created(override val date: Instant, val conversationId: String) : AgentEvent
/**
* Диалог удалён. Переданный [Conversation]-handle реализация обязана
* закрыть (`close()`) до эмиссии этого события — после [Deleted]
* пользоваться handle нельзя.
*/
@Serializable
@SerialName("deleted")
data class Deleted(override val date: Instant, val id: String) : AgentEvent
/** У диалога сменился заголовок. */
@Serializable
@SerialName("renamed")
data class Renamed(override val date: Instant, val id: String, val title: String?) : AgentEvent
}
@@ -0,0 +1,18 @@
package pw.binom.agentik.proto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
sealed interface Content {
@Serializable
@SerialName("text")
class Text(val body: String) : Content
/**
* Картинка. [mime] — MIME-тип, например `"image/png"`, `"image/jpeg"`.
*/
@Serializable
@SerialName("image")
class Image(val data: ByteArray, val mime: String) : Content
}
@@ -0,0 +1,88 @@
package pw.binom.agentik.proto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.time.Instant
/**
* Stateful-диалог клиента и [Agent]. Хранит собственную историю: на каждый
* [send] агенту не нужно пересылать транскрипт — он уже живёт внутри
* [Conversation].
*/
interface Conversation : AutoCloseable {
val id: String
val isSupportImageInput: Boolean
val isSupportImageOutput: Boolean
/**
* Признак временного диалога: не персистится между перезапусками агента,
* живёт только в памяти текущего процесса.
*/
val isTemporal: Boolean
val title: String?
/**
* Момент последнего изменения диалога (любой [send], [rename] и т.п.) в UTC.
* Используется для сортировки списка диалогов по свежести.
*/
val updatedAt: Instant
suspend fun rename(title: String)
/**
* Ставит новый user-ход в очередь. Возвращает управление сразу — поток
* событий ответа приходит через [events].
*
* Если в момент вызова выполняется другой ход, новый встаёт в очередь
* за ним. Чтобы отменить текущий — вызови [interrupt] перед [send].
*/
suspend fun send(content: List<Content>)
/**
* Прерывает текущий исполняемый ход (best-effort: LLM-stream прибивается,
* in-flight tool может доехать или отвалиться). В [events] эмитится
* [Event.Interrupted], затем может начаться следующий ход из очереди.
*
* Если хода нет — no-op.
*/
suspend fun interrupt()
/**
* Live-подписка на всё, что происходит в диалоге, начиная с [after].
*
* **Не реплеит** события, произошедшие до [after] — для бэкфилла
* используй [getMessages]. Если [after] — момент последнего виденного
* клиентом события, поток продолжается «с того места».
*
* Подписки независимы: каждый вызов возвращает свой [Flow], отмена одного
* не влияет на других подписчиков и на сам диалог.
*/
fun events(after: Instant): Flow<Event>
/** Страница истории: не более [limit] сообщений после [after], начиная с [offset]-го. */
suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<Message>
/** Все сообщения после [after] начиная с [offset], как поток: подгружает по [PAGE_SIZE] за раз. */
fun getMessages(after: Instant, offset: Int = 0): Flow<Message> = flow {
var skip = offset
while (true) {
val page = getMessages(after, skip, PAGE_SIZE)
if (page.isEmpty()) break
page.forEach { emit(it) }
skip += page.size
}
}
/**
* Освобождает ресурсы диалога (подписки, сетевые хэндлы). Идемпотентно.
* После [close] дальнейшие вызовы [send]/[interrupt]/[events]/[getMessages]/[rename] не определены.
*/
override fun close()
companion object {
const val PAGE_SIZE: Int = 100
}
}
@@ -0,0 +1,88 @@
package pw.binom.agentik.proto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Instant
/**
* Элемент live-потока [Conversation.events].
*
* Каждое событие несёт [date] — момент эмиссии в UTC. Используется клиентом
* для трекинга «где остановился» при обрыве/переподключении и для разрешения
* порядка при равных timestamps.
*
* Базовая структура хода:
* `StartReasoning?` → `StartResponse(TEXT|IMAGE)` → ...контент... → `End` | `Interrupted` | `Error`.
* `StartReasoning` может отсутствовать, если агент не показывал рассуждения.
*/
@Serializable
sealed interface Event {
/** Момент эмиссии события в UTC. */
val date: Instant
@Serializable
enum class ResponseType {
@SerialName("text") TEXT,
@SerialName("image") IMAGE
}
/** Ассистент начал рассуждение (опциональный маркер; контент рассуждения приходит через [AppendText]). */
@Serializable
@SerialName("start_reasoning")
data class StartReasoning(override val date: Instant) : Event
/** Начало ответа ассистента заданного типа. После него идут соответствующие `Append*`/`Tool*`-события, потом [End]/[Interrupted]/[Error]. */
@Serializable
@SerialName("start_response")
data class StartResponse(override val date: Instant, val responseType: ResponseType) : Event
/** Ход завершён нормально. Соответствующий [Message.AssistantMessage] появится в `getMessages`. */
@Serializable
@SerialName("end")
data class End(override val date: Instant) : Event
/** Ход прерван через [Conversation.interrupt]. Частичный ответ НЕ сохраняется в истории. */
@Serializable
@SerialName("interrupted")
data class Interrupted(override val date: Instant) : Event
@Serializable
@SerialName("append_text")
data class AppendText(override val date: Instant, val body: String) : Event
@Serializable
@SerialName("append_image")
data class AppendImage(override val date: Instant, val body: ByteArray, val mime: String) : Event
/**
* Агент начал вызов тула. Аргументы приходят целиком — стриминга нет.
* [id] совпадает с id соответствующего [Message.ToolCall] в истории
* после завершения хода.
*/
@Serializable
@SerialName("tool_call")
data class ToolCall(
override val date: Instant,
val id: String,
val title: String?,
val toolName: String,
val toolArgs: String,
) : Event
/**
* Результат вызова тула. Приходит целиком после завершения исполнения.
* [id] совпадает с [ToolCall.id], к которому относится результат, и
* с id [Message.ToolResult] в истории.
*/
@Serializable
@SerialName("tool_result")
data class ToolResult(override val date: Instant, val id: String, val result: String?) : Event
/**
* Ошибка хода. После неё поток завершается; дальнейшие события могут
* прийти, но ход считается проваленным.
*/
@Serializable
@SerialName("error")
data class Error(override val date: Instant, val message: String, val code: String? = null) : Event
}
@@ -0,0 +1,42 @@
package pw.binom.agentik.proto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Instant
@Serializable
sealed interface Message {
/**
* Уникальный идентификатор сообщения в рамках диалога. Стабилен между
* стримом [Event] и историей: id, пришедший в [Event.ToolCall], равен
* id соответствующего [ToolCall] в истории после завершения хода.
*/
val id: String
/**
* Дата сообщения в UTC
*/
val date: Instant
@Serializable
@SerialName("user_message")
class UserMessage(override val id: String, val content: List<Content>, override val date: Instant) : Message
@Serializable
@SerialName("assistant_message")
class AssistantMessage(override val id: String, val content: List<Content>, override val date: Instant) : Message
@Serializable
@SerialName("tool_call")
class ToolCall(
override val id: String,
val title: String?,
val toolName: String,
val toolArgs: String,
override val date: Instant
) : Message
@Serializable
@SerialName("tool_result")
class ToolResult(override val id: String, val result: String?, override val date: Instant) : Message
}
+23
View File
@@ -0,0 +1,23 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
dependencies {
implementation(project(":proto"))
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
}
@@ -0,0 +1,34 @@
package pw.binom.agentik.server
import kotlinx.serialization.Serializable
import pw.binom.agentik.proto.Conversation
import kotlin.time.Instant
/**
* HTTP-снимок [Conversation] — те же поля, что у интерфейса, но без методов.
* Сериализуется в JSON и обратно.
*/
@Serializable
data class ConversationSnapshot(
val id: String,
val isSupportImageInput: Boolean,
val isSupportImageOutput: Boolean,
val isTemporal: Boolean,
val title: String? = null,
val updatedAt: Instant,
)
internal fun Conversation.snapshot(): ConversationSnapshot = ConversationSnapshot(
id = id,
isSupportImageInput = isSupportImageInput,
isSupportImageOutput = isSupportImageOutput,
isTemporal = isTemporal,
title = title,
updatedAt = updatedAt,
)
@Serializable
internal data class RequestCreateConversation(val temp: Boolean)
@Serializable
internal data class RequestRename(val title: String)
@@ -0,0 +1,43 @@
package pw.binom.agentik.server
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.Route
import io.ktor.server.routing.route
import pw.binom.agentik.proto.Agent
/**
* Встраивает HTTP/SSE-фасад протокола agentik в твой Ktor-роутинг.
*
* Использование:
* ```
* embeddedServer(Netty, port = 8080) {
* routing {
* agentikAgent(MyAgent()) // все роуты под /agentik
* agentikAgent(MyAgent(), "/api/chat") // или под произвольным префиксом
* }
* }.start(wait = true)
* ```
*
* Под префиксом [path] монтируются:
* - `POST /conversations` — создать диалог
* - `GET /conversations` — список
* - `GET /conversations/{id}` — один диалог
* - `PATCH /conversations/{id}` — переименовать
* - `DELETE /conversations/{id}` — удалить
* - `POST /conversations/{id}/messages` — `send` (202 Accepted)
* - `POST /conversations/{id}/interrupt` — `interrupt`
* - `GET /conversations/{id}/messages` — история
* - `GET /conversations/{id}/events` — SSE: события хода
* - `GET /events` — SSE: события агента
* - `GET /health` — `"ok"`
*/
fun Route.agentikAgent(agent: Agent, path: String = "/agentik") {
route(path) {
install(ContentNegotiation) {
json(agentikJson)
}
agentikRoutes(agent)
}
}
@@ -0,0 +1,160 @@
package pw.binom.agentik.server
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.response.respondTextWriter
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.patch
import io.ktor.server.routing.post
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import pw.binom.agentik.proto.Agent
import pw.binom.agentik.proto.AgentEvent
import pw.binom.agentik.proto.Conversation
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event
import kotlin.time.Instant
internal fun Route.agentikRoutes(agent: Agent) {
get("/health") {
call.respondText("ok")
}
// ---- Agent: множество диалогов ----
get("/conversations") {
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: Agent.PAGE_SIZE
call.respond(agent.getConversations(offset, limit).map { it.snapshot() })
}
post("/conversations") {
val req = call.receive<RequestCreateConversation>()
val c = agent.createConversation(req.temp)
call.respond(HttpStatusCode.Created, c.snapshot())
}
get("/conversations/{id}") {
val id = call.parameters["id"]!!
val c = agent.getConversation(id)
if (c == null) call.respond(HttpStatusCode.NotFound)
else call.respond(c.snapshot())
}
delete("/conversations/{id}") {
val id = call.parameters["id"]!!
call.respond(if (agent.deleteConversation(id)) HttpStatusCode.NoContent else HttpStatusCode.NotFound)
}
// ---- Conversation ----
patch("/conversations/{id}") {
val id = call.parameters["id"]!!
val c = agent.getConversation(id)
if (c == null) {
call.respond(HttpStatusCode.NotFound)
return@patch
}
val req = call.receive<RequestRename>()
c.rename(req.title)
call.respond(c.snapshot())
}
post("/conversations/{id}/messages") {
val id = call.parameters["id"]!!
val c = agent.getConversation(id)
if (c == null) {
call.respond(HttpStatusCode.NotFound)
return@post
}
val content = call.receive<List<Content>>()
c.send(content)
call.respond(HttpStatusCode.Accepted)
}
post("/conversations/{id}/interrupt") {
val id = call.parameters["id"]!!
val c = agent.getConversation(id)
if (c == null) {
call.respond(HttpStatusCode.NotFound)
return@post
}
c.interrupt()
call.respond(HttpStatusCode.Accepted)
}
get("/conversations/{id}/messages") {
val id = call.parameters["id"]!!
val c = agent.getConversation(id)
if (c == null) {
call.respond(HttpStatusCode.NotFound)
return@get
}
val after = call.parseAfter() ?: return@get
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: Conversation.PAGE_SIZE
call.respond(c.getMessages(after, offset, limit))
}
// ---- SSE ----
get("/conversations/{id}/events") {
val id = call.parameters["id"]!!
val c = agent.getConversation(id)
if (c == null) {
call.respond(HttpStatusCode.NotFound)
return@get
}
val after = call.parseAfter() ?: return@get
call.streamJsonSse(c.events(after), Event.serializer())
}
get("/events") {
val after = call.parseAfter() ?: return@get
call.streamJsonSse(agent.events(after), AgentEvent.serializer())
}
}
// ---------- helpers ----------
/**
* Парсит query-параметр `after` как ISO-8601 [Instant]. Отсутствие = [Instant.DISTANT_PAST].
* При невалидном значении отвечает 400 и возвращает `null`.
*/
private suspend fun ApplicationCall.parseAfter(): Instant? {
val raw = request.queryParameters["after"]
if (raw == null) return Instant.DISTANT_PAST
return try {
Instant.parse(raw)
} catch (_: IllegalArgumentException) {
respond(HttpStatusCode.BadRequest, "Invalid 'after' (expected ISO-8601): $raw")
null
}
}
private suspend fun <T> ApplicationCall.streamJsonSse(
flow: Flow<T>,
serializer: KSerializer<T>,
json: Json = agentikJson,
) {
respondTextWriter(contentType = ContentType.Text.EventStream) {
flow.catch { /* клиент отвалился — глушим */ }
.collect { value ->
val s = json.encodeToString(serializer, value)
write("data: ")
write(s)
write("\n\n")
flush()
}
}
}
@@ -0,0 +1,30 @@
package pw.binom.agentik.server
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlin.time.Instant
internal object InstantSerializer : KSerializer<Instant> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("pw.binom.agentik.Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) =
encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Instant =
Instant.parse(decoder.decodeString())
}
internal val agentikJson: Json = Json {
ignoreUnknownKeys = true
explicitNulls = false
serializersModule = SerializersModule {
contextual(Instant::class, InstantSerializer)
}
}
+32
View File
@@ -0,0 +1,32 @@
val caffeineRepo = providers.gradleProperty("caffeineRepo").getOrElse("http://192.168.76.117/repository/caffeine")
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
google()
}
}
dependencyResolutionManagement {
repositories {
mavenCentral()
google()
// Home Nexus, репо "caffeine": pw.binom.* (AG-UI, A2A, ...)
maven {
name = "caffeine"
url = uri(caffeineRepo)
setAllowInsecureProtocol(true)
}
}
}
rootProject.name = "agentik"
include(":standalone")
// Собственный протокол agentik (замена AG-UI). Пока в нём пилим, потом вынесем.
include(":proto")
// Ktor-сервер, экспонирующий Agent по HTTP (SSE + JSON).
include(":server")
// Ktor-клиент, превращающий HTTP-фасад в `Agent`/`Conversation`.
include(":client")
+91
View File
@@ -0,0 +1,91 @@
@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.targets.jvm.KotlinJvmTarget
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.sqldelight)
}
kotlin {
jvmToolchain(21)
jvm {
binaries {
executable {
mainClass.set("pw.binom.agentik.standalone.MainKt")
}
}
}
sourceSets {
commonMain.dependencies {
implementation(project(":proto"))
implementation(project(":server"))
// Commons
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime)
// litert-kmp: контракт (commonMain)
api(libs.litert.api)
// SQLDelight runtime (commonMain)
api(libs.sqldelight.runtime)
api(libs.sqldelight.coroutines)
}
jvmMain.dependencies {
// litert-openai: JVM-реализация
implementation(libs.litert.openai)
// litert-google: встроенный LiteRT-LM движок, нужен только на runtime
runtimeOnly(libs.litert.google)
// SQLDelight JDBC driver (JVM)
implementation(libs.sqldelight.sqlite.driver)
// Ktor server (для :server facade + a2aServer)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.sse)
implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
// Транспортные фасады
implementation(libs.agui.server)
implementation(libs.a2a.server)
// MCP (Model Context Protocol) клиент — подключение внешних/внутренних MCP-серверов
implementation(libs.mcp.sdk.client)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
}
commonTest.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlin.test)
}
jvmTest.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlin.test)
// Ktor test engine для smoke-тестов HTTP
implementation(libs.ktor.server.test.host)
}
}
}
sqldelight {
databases {
create("AgentikDatabase") {
packageName.set("pw.binom.agentik.standalone.persistence.sqlite")
srcDirs.setFrom("src/jvmMain/sqldelight")
}
}
}
@@ -0,0 +1,29 @@
package pw.binom.agentik.standalone.persistence
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Часть контента сообщения на уровне хранилища.
*
* Намеренно НЕ зависит от [pw.binom.agentik.proto.Content] — маппинг
* `:proto.Content ↔ Content` живёт в `Mapping.kt`. Структурно типы
* идентичны, но даёт возможность заменить transport-протокол без миграции
* таблиц.
*
* Image сериализуется в JSON через base64 (стандарт для kotlinx-serialization).
*/
@Serializable
sealed interface Content {
@Serializable
@SerialName("text")
data class Text(val body: String) : Content
@Serializable
@SerialName("image")
data class Image(val data: ByteArray, val mime: String) : Content {
override fun equals(other: Any?): Boolean =
this === other || (other is Image && mime == other.mime && data.contentEquals(other.data))
override fun hashCode(): Int = 31 * mime.hashCode() + data.contentHashCode()
}
}
@@ -0,0 +1,14 @@
package pw.binom.agentik.standalone.persistence
import kotlin.time.Instant
/**
* Snapshot диалога. В таблице `conversation` хранится как есть.
*/
data class ConversationRecord(
val id: String,
val title: String?,
val isTemporal: Boolean,
val createdAt: Instant,
val updatedAt: Instant,
)
@@ -0,0 +1,27 @@
package pw.binom.agentik.standalone.persistence
import kotlin.time.Instant
/**
* CRUD по таблице `conversation`.
*/
interface ConversationStore : AutoCloseable {
/** Создать или обновить snapshot диалога. */
suspend fun upsert(record: ConversationRecord)
/** Диалог по id, или `null`. */
suspend fun get(id: String): ConversationRecord?
/** Удалить диалог (вместе с его сообщениями и working memory). */
suspend fun delete(id: String): Boolean
/** Список диалогов, отсортированный по `updatedAt` DESC. */
suspend fun list(offset: Int, limit: Int): List<ConversationRecord>
/** Переименовать диалог; `null` для сброса заголовка. Возвращает новый `updatedAt` или `null`, если не найден. */
suspend fun rename(id: String, title: String?): Instant?
/** Обновить `updatedAt` диалога (например, после отправки сообщения). */
suspend fun touch(id: String, now: Instant)
}
@@ -0,0 +1,91 @@
package pw.binom.agentik.standalone.persistence
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Instant
/**
* Запись в таблице `message` (append-only audit) и `working_memory` (mutable view).
*
* Используется sealed-иерархия: подтипы `User`/`Assistant`/`ToolCall`/`ToolResult`
* живут и там, и там. `Summary`/`System` — только в `working_memory`
* (синтетические строки, созданные при суммаризации или как system-prompt).
*
* Все подтипы несут [id] (UUID, стабильный между лайв-стримом Event и историей),
* [conversationId] и [createdAt].
*
* Поля, специфичные для подтипа, сериализуются в JSON в `payload_json`
* колонке SQLite — это даёт гибкость без миграций при добавлении полей.
*/
@Serializable
sealed interface MessageRecord {
val id: String
val conversationId: String
val createdAt: Instant
/** Подтип сообщения с телом из [Content]. */
@Serializable
sealed interface Body : MessageRecord {
val content: List<Content>
}
@Serializable
@SerialName("user")
data class UserMessage(
override val id: String,
override val conversationId: String,
override val content: List<Content>,
override val createdAt: Instant,
) : Body
@Serializable
@SerialName("assistant")
data class AssistantMessage(
override val id: String,
override val conversationId: String,
override val content: List<Content>,
override val createdAt: Instant,
) : Body
@Serializable
@SerialName("tool_call")
data class ToolCall(
override val id: String,
override val conversationId: String,
val toolName: String,
val toolTitle: String?,
val toolArgsJson: String,
override val createdAt: Instant,
) : MessageRecord
@Serializable
@SerialName("tool_result")
data class ToolResult(
override val id: String,
override val conversationId: String,
val toolCallId: String,
val result: String?,
override val createdAt: Instant,
) : MessageRecord
/** Синтетическое: суммаризация старого контекста. Только в working_memory. */
@Serializable
@SerialName("summary")
data class Summary(
override val id: String,
override val conversationId: String,
val text: String,
override val createdAt: Instant,
) : MessageRecord
/** Синтетическое: system-prompt, введённый при создании диалога. Только в working_memory. */
@Serializable
@SerialName("system")
data class System(
override val id: String,
override val conversationId: String,
val text: String,
override val createdAt: Instant,
) : MessageRecord
}
@@ -0,0 +1,24 @@
package pw.binom.agentik.standalone.persistence
import kotlin.time.Instant
/**
* Append-only audit log сообщений (`message` table).
*
* Только `insert` и чтение. Никаких обновлений, никакого удаления (кроме
* каскадного удаления вместе с [ConversationStore.delete]).
*/
interface MessageStore : AutoCloseable {
/** Добавить запись в audit log. `conversationId` берётся из [MessageRecord.conversationId]. */
suspend fun append(record: MessageRecord)
/**
* Страница audit-сообщений диалога после [after] (UTC), отсортированная
* по `createdAt ASC`. Для первоначальной загрузки передай `Instant.DISTANT_PAST`.
*/
suspend fun list(conversationId: String, after: Instant, offset: Int, limit: Int): List<MessageRecord>
/** Все сообщения диалога, отсортированные по `createdAt ASC` (для rebuild working memory). */
suspend fun listAll(conversationId: String): List<MessageRecord>
}
@@ -0,0 +1,26 @@
package pw.binom.agentik.standalone.persistence
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
/**
* JSON-формат для тел user/assistant сообщений: список [Content],
* сериализованный в строку (через kotlinx-serialization).
*/
private val bodyJson = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
/**
* Сериализует список [Content] в JSON-строку для хранения в `payload_json`.
*/
fun encodeBodyPayload(content: List<Content>): String =
bodyJson.encodeToString(ListSerializer(Content.serializer()), content)
/**
* Десериализует список [Content] из JSON-строки `payload_json`.
*/
fun decodeBodyPayload(json: String): List<Content> =
bodyJson.decodeFromString(ListSerializer(Content.serializer()), json)
@@ -0,0 +1,42 @@
package pw.binom.agentik.standalone.persistence
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Запись в working memory диалога: ровно то, что агент сейчас видит в
* LLM-контексте. Упорядочено по `order_idx` (заполняется в store при append).
*
* Sealed-иерархия: для v1 — `System` (синтетический system-prompt),
* `User`/`Assistant` (реплики с ссылкой на audit log через [sourceMessageId]).
* Суммаризация (для v2) добавит подтип `Summary`.
*/
@Serializable
sealed interface WorkingMemoryEntry {
/** Ссылка на исходное сообщение в audit log (`message.id`). `null` для синтетических строк. */
val sourceMessageId: String?
/** Синтетический system-prompt, добавляется при создании диалога. */
@Serializable
@SerialName("system")
data class System(val text: String) : WorkingMemoryEntry {
override val sourceMessageId: String? = null
}
/** Реплика пользователя. */
@Serializable
@SerialName("user")
data class User(
override val sourceMessageId: String,
val content: List<Content>,
) : WorkingMemoryEntry
/** Реплика ассистента. */
@Serializable
@SerialName("assistant")
data class Assistant(
override val sourceMessageId: String,
val content: List<Content>,
) : WorkingMemoryEntry
}
@@ -0,0 +1,50 @@
package pw.binom.agentik.standalone.persistence
import kotlin.time.Instant
/**
* Одна строка `working_memory` таблицы (внутреннее представление store).
*
* Используется для тестов и для перестроения [WorkingMemoryEntry] из row.
* Агент не должен с этим типом работать напрямую — он работает с
* [WorkingMemoryEntry] через [WorkingMemoryStore].
*/
data class WorkingMemoryRow(
val id: String,
val conversationId: String,
val orderIdx: Long,
val sourceMessageId: String?,
val entry: WorkingMemoryEntry,
val createdAt: Instant,
)
/**
* Мутируемое представление LLM-контекста диалога (`working_memory` table).
*
* Аудит-лог — [MessageStore], неизменный; здесь — ровно то, что агент сейчас
* «видит»: системный промпт + реплики + (опционально) суммаризации.
* Строки упорядочены по `order_idx` ASC; `source_message_id` NULL указывает
* на синтетические строки (System, а в v2 — Summary).
*
* Суммаризация / чистка — один атомарный вызов [compact].
*/
interface WorkingMemoryStore : AutoCloseable {
/** Добавить запись в конец working memory (новый максимальный `order_idx`). */
suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant)
/** Все строки working memory диалога в порядке отправки. */
suspend fun list(conversationId: String): List<WorkingMemoryRow>
/** Очистить working memory диалога (используется при reset/rebuild). */
suspend fun clear(conversationId: String)
/**
* Атомарная суммаризация (v2): удаляет все строки с `order_idx` в диапазоне
* `[dropFromOrderIdx, +∞)` и вставляет вместо них новый `summary` с
* указанным текстом. Возвращает новый максимальный `order_idx`.
*
* Для v1 просто удаляет — суммаризация появится в v2.
*/
suspend fun compact(dropFromOrderIdx: Long, conversationId: String): Long
}
@@ -0,0 +1,72 @@
package pw.binom.agentik.standalone
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import pw.binom.agentik.server.agentikAgent
import pw.binom.agentik.standalone.agent.ChatAgent
import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.standalone.mcp.McpConfig
import pw.binom.agentik.standalone.mcp.McpRegistry
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
/**
* standalone-контейнер agentik:
* - :server (proto): встраиваемый Ktor (Netty), порт AGENTIK_PORT (default 8080)
* POST /agentik/conversations -> ConversationSnapshot (201)
* GET /agentik/conversations -> [ConversationSnapshot]
* GET /agentik/conversations/{id} -> ConversationSnapshot
* PATCH /agentik/conversations/{id} -> ConversationSnapshot
* DELETE /agentik/conversations/{id} -> 204
* POST /agentik/conversations/{id}/messages -> 202
* POST /agentik/conversations/{id}/interrupt -> 202
* GET /agentik/conversations/{id}/messages -> [Message]
* GET /agentik/conversations/{id}/events -> text/event-stream (SSE)
* GET /agentik/events -> text/event-stream (SSE, Agent-level)
* GET /health -> "ok"
*
* Хранилище — SQLite (env: AGENTIK_DB_PATH, default `./agentik.db`, `:memory:` для тестов).
* LLM — litert-openai (env: OPENAI_BASE_URL, OPENAI_API_KEY, OPENAI_MODEL) или litert-google
* (env: AGENTIK_LLM_BACKEND=google, AGENTIK_GOOGLE_MODEL_PATH).
* MCP — AGENTIK_MCP_CONFIG=<path>.json (формат Claude Desktop).
*
* System prompt — AGENTIK_SYSTEM_PROMPT (default: встроенный `Ты полезный ассистент...`).
*/
fun main() {
val port = System.getenv("AGENTIK_PORT")?.toIntOrNull() ?: 8080
val dbPath = System.getenv("AGENTIK_DB_PATH")?.takeIf { it.isNotBlank() } ?: "./agentik.db"
val llmConfig = LlmConfig.fromEnv()
val llm = llmConfig.createLlm()
val stores = SqliteStores.open(dbPath = dbPath)
val mcpRegistry = McpRegistry.fromConfig(McpConfig.fromEnv())
val agent = ChatAgent(
id = "agentik",
stores = stores,
llm = llm,
llmConfig = llmConfig,
tools = mcpRegistry.namedTools,
)
val server = embeddedServer(Netty, port = port) {
routing {
get("/health") { call.respondText("ok") }
agentikAgent(agent, path = "/agentik")
}
}
println("agentik standalone listening on http://localhost:$port")
println(" GET /health")
println(" POST /agentik/conversations -> 201")
println(" GET /agentik/conversations/{id}/events -> SSE")
println(" storage: $dbPath")
println(" llm: ${llmConfig.backend} ${llmConfig.modelInfo()}")
println(" mcp: ${mcpRegistry.allTools.size} tools from ${mcpRegistry.connectedServerCount} servers")
Runtime.getRuntime().addShutdownHook(Thread {
agent.close()
mcpRegistry.close()
stores.close()
llm.close()
})
server.start(wait = true)
}
@@ -0,0 +1,122 @@
package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.proto.Agent as ProtoAgent
import pw.binom.agentik.proto.AgentEvent
import pw.binom.agentik.proto.Conversation as ProtoConversation
import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.standalone.persistence.ConversationRecord
import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
import pw.binom.litert.LiteLlm
import kotlin.time.Instant
/**
* Stateful [ProtoAgent] на базе SQLite (история + working memory) и
* [LiteLlm] (универсальный LLM-контракт).
*
* Создаёт [ChatConversation] — те самые stateful диалоги, которые
* хранят свой собственный LiteConversation в ОЗУ (для скорости) и
* логируют в SQLite (для долговечности).
*
* Один [LiteLlm] шарится между всеми беседами агента.
*/
class ChatAgent(
override val id: String,
private val stores: SqliteStores,
private val llm: LiteLlm,
private val llmConfig: LlmConfig,
private val tools: List<NamedTool> = emptyList(),
) : ProtoAgent, AutoCloseable {
private val agentEvents = MutableSharedFlow<AgentEvent>(
extraBufferCapacity = 64,
)
/** Защищает карту живых диалогов. */
private val liveLock = Mutex()
private val live: MutableMap<String, ChatConversation> = HashMap()
/** Live-подписка на события уровня агента (создание/удаление/переименование). */
override fun events(after: Instant): Flow<AgentEvent> {
// Реализация событийной шины упрощённая: возвращаем общий поток.
// Фильтр по `after` не делаем — для v1 после-семантика не нужна
// (см. Memory #3704: replay-free, бэкфилл через getConversations/getConversation).
return agentEvents.asSharedFlow()
}
override fun createConversation(temp: Boolean): ProtoConversation {
val now = now()
val id = "conv-${java.util.UUID.randomUUID()}"
val rec = ConversationRecord(
id = id,
title = null,
isTemporal = temp,
createdAt = now,
updatedAt = now,
)
// Temp-беседы не пишем в SQLite — они живут только в RAM-карте `live`
// и не переживают рестарт агента (см. Memory #3709).
if (!temp) {
runBlocking {
stores.conversations.upsert(rec)
stores.workingMemory.append(
conversationId = id,
entry = WorkingMemoryEntry.System(text = llmConfig.systemPrompt),
now = now,
)
}
}
val conv = ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = llmConfig.systemPrompt, tools = tools)
runBlocking {
liveLock.withLock { live[conv.id] = conv }
}
agentEvents.tryEmit(AgentEvent.Created(date = now(), conversationId = conv.id))
return conv
}
override suspend fun getConversation(id: String): ProtoConversation? {
liveLock.withLock { live[id] }?.let { if (!it.isClosed) return it }
val rec = stores.conversations.get(id) ?: return null
return ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = llmConfig.systemPrompt, tools = tools).also {
liveLock.withLock { live[id] = it }
}
}
override suspend fun deleteConversation(id: String): Boolean {
val conv = liveLock.withLock { live.remove(id) }
conv?.close()
val ok = stores.conversations.delete(id)
if (ok) agentEvents.tryEmit(AgentEvent.Deleted(date = now(), id = id))
return ok
}
override suspend fun getConversations(offset: Int, limit: Int): List<ProtoConversation> =
stores.conversations.list(offset = offset, limit = limit).map { rec ->
liveLock.withLock { live[rec.id] }
?: ChatConversation(record = rec, stores = stores, llm = llm, systemPrompt = llmConfig.systemPrompt).also {
liveLock.withLock { live[rec.id] = it }
}
}
override fun close() {
runBlocking {
liveLock.withLock {
live.values.forEach { it.close() }
live.clear()
}
}
runCatching { llm.close() }
}
internal fun unregister(id: String) {
runBlocking { liveLock.withLock { live.remove(id) } }
}
private fun now(): Instant = Instant.fromEpochMilliseconds(System.currentTimeMillis())
}
@@ -0,0 +1,439 @@
package pw.binom.agentik.standalone.agent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.agentik.proto.Content as ProtoContent
import pw.binom.agentik.proto.Conversation as ProtoConversation
import pw.binom.agentik.proto.Event as ProtoEvent
import pw.binom.agentik.proto.Message as ProtoMessage
import pw.binom.agentik.standalone.persistence.Content
import pw.binom.agentik.standalone.persistence.ConversationRecord
import pw.binom.agentik.standalone.persistence.ConversationStore
import pw.binom.agentik.standalone.persistence.MessageRecord
import pw.binom.agentik.standalone.persistence.MessageStore
import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry
import pw.binom.agentik.standalone.persistence.WorkingMemoryStore
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
import pw.binom.litert.LiteContentPart
import pw.binom.litert.LiteConversation
import pw.binom.litert.LiteConversationConfig
import pw.binom.litert.LiteLlm
import pw.binom.litert.LiteMessage
import pw.binom.litert.LiteRole
import pw.binom.litert.LiteToolCall
import kotlin.time.Instant
/**
* Stateful [pw.binom.agentik.proto.Conversation] поверх [LiteLlm].
*
* Один [LiteConversation] живёт всю беседу (требование абстракции LiteLlm — у реализаций
* внутри `LiteConversation` хранится KV-cache движка / инкрементальная история).
*
* На каждый [send]:
* 1. Записывает user-сообщение в audit log + working memory (если не temp).
* 2. Создаёт [LiteConversation] **один раз** при первом send с initial messages из
* working memory (без только что записанного user-сообщения — мы его отдадим через
* [LiteConversation.sendStreamContents]).
* 3. Стримит ответ [LiteDelta]:
* - text → [ProtoEvent.AppendText]
* - toolCalls → выполняем через [tools] (MCP), эмитим [ProtoEvent.ToolCall]/[ProtoEvent.ToolResult],
* подаём результат через [LiteConversation.addToolResult], продолжаем стрим
* до тех пор, пока модель не перестанет вызывать тулы.
* 4. По завершении записывает AssistantMessage в audit + working memory.
*
* При ошибке LiteConversation выбрасывается и пересоздаётся на следующий [send].
*/
class ChatConversation(
record: ConversationRecord,
private val stores: SqliteStores,
private val llm: LiteLlm,
private val systemPrompt: String,
private val tools: List<NamedTool> = emptyList(),
) : ProtoConversation, AutoCloseable {
private var record: ConversationRecord = record
override val id: String get() = record.id
override val isSupportImageInput: Boolean get() = false
override val isSupportImageOutput: Boolean get() = false
override val isTemporal: Boolean get() = record.isTemporal
override val title: String? get() = record.title
override val updatedAt: Instant get() = record.updatedAt
private val conversationStore: ConversationStore get() = stores.conversations
private val messageStore: MessageStore get() = stores.messages
private val workingMemory: WorkingMemoryStore get() = stores.workingMemory
private val toolsByName: Map<String, NamedTool> = tools.associateBy { it.name }
private val events = MutableSharedFlow<ProtoEvent>(
replay = 0,
extraBufferCapacity = 256,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private val turnLock = Mutex()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile
private var liteConv: LiteConversation? = null
@Volatile
private var activeTurn: Job? = null
@Volatile
private var closed = false
internal val isClosed: Boolean get() = closed
override suspend fun rename(title: String) {
val newRecord = conversationStore.rename(id, title)?.let { ts ->
record.copy(title = title, updatedAt = ts)
} ?: record.copy(title = title)
record = newRecord
}
override suspend fun send(content: List<ProtoContent>) {
check(!closed) { "Conversation closed: $id" }
val turnStarted = now()
val userMessageId = newId("msg")
val userRecord = MessageRecord.UserMessage(
id = userMessageId,
conversationId = id,
content = content.map { it.toStorage() },
createdAt = turnStarted,
)
if (!record.isTemporal) {
messageStore.append(userRecord)
workingMemory.append(
conversationId = id,
entry = WorkingMemoryEntry.User(
sourceMessageId = userMessageId,
content = userRecord.content,
),
now = turnStarted,
)
}
activeTurn = scope.launch {
turnLock.withLock {
runTurn(userRecord, turnStarted)
}
}
activeTurn?.join()
}
override suspend fun interrupt() {
runCatching { liteConv?.cancel() }
activeTurn?.cancel()
emitEvent(ProtoEvent.Interrupted(date = now()))
}
override fun events(after: Instant): Flow<ProtoEvent> =
events.asSharedFlow()
override suspend fun getMessages(after: Instant, offset: Int, limit: Int): List<ProtoMessage> =
messageStore.list(conversationId = id, after = after, offset = offset, limit = limit)
.map { it.toProto() }
override fun close() {
if (closed) return
closed = true
runCatching { liteConv?.close() }
runCatching { activeTurn?.cancel() }
scope.cancel()
}
/**
* Один ход: user → (assistant → tool → ... → assistant)*.
*
* Tool-loop: после каждого `sendStreamContents` смотрим `delta.toolCalls`. Если есть —
* исполняем, подаём результат через `addToolResult`, делаем ещё один send (с пустым
* user-сообщением как триггером продолжения — модель уже знает, что делать дальше
* по tool-results в истории), повторяем. Защита от зацикливания — [MAX_TOOL_LOOPS].
*/
private suspend fun runTurn(userRecord: MessageRecord.UserMessage, turnStarted: Instant) {
emitEvent(ProtoEvent.StartReasoning(date = turnStarted))
emitEvent(ProtoEvent.StartResponse(date = now(), responseType = ProtoEvent.ResponseType.TEXT))
val parts = userRecord.content.mapNotNull { c ->
when (c) {
is Content.Text -> LiteContentPart.Text(c.body)
is Content.Image -> {
System.err.println("[agentik] dropping image input (v1 text-only): mime=${c.mime}, ${c.data.size} bytes")
null
}
}
}
if (parts.isEmpty()) {
emitEvent(ProtoEvent.Error(date = now(), message = "Empty user input (no text content)"))
emitEvent(ProtoEvent.End(date = now()))
return
}
val liteConv = try {
getOrCreateLiteConversation(excludeUserSourceId = if (record.isTemporal) null else userRecord.id)
} catch (e: Throwable) {
this.liteConv = null
emitEvent(ProtoEvent.Error(date = now(), message = e.message ?: "LiteConversation init failed"))
emitEvent(ProtoEvent.End(date = now()))
return
}
val reply = StringBuilder()
var currentParts: List<LiteContentPart> = parts
var loopGuard = 0
while (loopGuard++ < MAX_TOOL_LOOPS) {
val collectedCalls = mutableListOf<LiteToolCall>()
try {
liteConv.sendStreamContents(currentParts).collect { delta ->
if (delta.text.isNotEmpty()) {
reply.append(delta.text)
emitEvent(ProtoEvent.AppendText(date = now(), body = delta.text))
}
if (delta.toolCalls.isNotEmpty()) {
collectedCalls.addAll(delta.toolCalls)
}
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Throwable) {
emitEvent(ProtoEvent.Error(date = now(), message = e.message ?: e.javaClass.simpleName))
emitEvent(ProtoEvent.End(date = now()))
return
}
if (collectedCalls.isEmpty()) break
for (call in collectedCalls) {
executeToolCall(liteConv, call)
}
// Continuation: send a no-op user message so the engine produces the next
// assistant response (which will see the tool results we just fed via
// addToolResult in its history). The leading newline + space is a benign
// trigger — every LLM treats it as "please continue".
currentParts = listOf(LiteContentPart.Text(" "))
}
if (loopGuard >= MAX_TOOL_LOOPS) {
System.err.println("[agentik] tool loop hit MAX_TOOL_LOOPS=$MAX_TOOL_LOOPS for $id — bailing")
}
val assistantId = newId("msg")
val assistantAt = now()
val assistantContent = listOf(Content.Text(reply.toString()))
val assistantRecord = MessageRecord.AssistantMessage(
id = assistantId,
conversationId = id,
content = assistantContent,
createdAt = assistantAt,
)
if (!record.isTemporal) {
messageStore.append(assistantRecord)
workingMemory.append(
conversationId = id,
entry = WorkingMemoryEntry.Assistant(
sourceMessageId = assistantId,
content = assistantContent,
),
now = assistantAt,
)
record = record.copy(updatedAt = assistantAt)
conversationStore.touch(id, assistantAt)
}
emitEvent(ProtoEvent.End(date = assistantAt))
}
/**
* Один tool-call: эмитим Event.ToolCall, выполняем tool (MCP), эмитим Event.ToolResult,
* пишем в audit + working memory, подаём результат в LiteConversation.
*/
private suspend fun executeToolCall(liteConv: LiteConversation, call: LiteToolCall) {
val callId = newId("tc")
val resultId = newId("tr")
val argsJson = encodeArgsJson(call.arguments)
val nowTs = now()
emitEvent(ProtoEvent.ToolCall(date = nowTs, id = callId, title = null, toolName = call.name, toolArgs = argsJson))
if (!record.isTemporal) {
messageStore.append(
MessageRecord.ToolCall(
id = callId,
conversationId = id,
toolName = call.name,
toolTitle = null,
toolArgsJson = argsJson,
createdAt = nowTs,
),
)
}
val tool = toolsByName[call.name]
val resultText: String = if (tool == null) {
System.err.println("[agentik] tool '${call.name}' requested but not registered")
"[tool not found: ${call.name}]"
} else {
try {
tool.tool.invoke(argsJson).ifBlank { "<empty result>" }
} catch (e: Throwable) {
System.err.println("[agentik] tool '${call.name}' threw: ${e.message}")
"[tool error: ${e.message ?: e.javaClass.simpleName}]"
}
}
emitEvent(ProtoEvent.ToolResult(date = now(), id = resultId, result = resultText))
if (!record.isTemporal) {
messageStore.append(
MessageRecord.ToolResult(
id = resultId,
conversationId = id,
toolCallId = callId,
result = resultText,
createdAt = now(),
),
)
}
liteConv.addToolResult(callId = callId, name = call.name, result = resultText)
}
/**
* Возвращает существующий [LiteConversation] или создаёт новый, инициализированный
* системным промптом и прошлыми User/Assistant из working memory.
*/
private suspend fun getOrCreateLiteConversation(excludeUserSourceId: String? = null): LiteConversation {
liteConv?.let { return it }
val wm = if (record.isTemporal) emptyList() else workingMemory.list(id)
val resolvedSystemPrompt = if (record.isTemporal) systemPrompt else wm
.firstOrNull { it.entry is WorkingMemoryEntry.System }
?.let { (it.entry as WorkingMemoryEntry.System).text }
?: systemPrompt
val pastTurns = if (record.isTemporal) emptyList() else wm
.filter { row ->
val isUserOrAssistant = row.entry is WorkingMemoryEntry.User || row.entry is WorkingMemoryEntry.Assistant
val isPendingUser = excludeUserSourceId != null && row.sourceMessageId == excludeUserSourceId
isUserOrAssistant && !isPendingUser
}
.map { row ->
when (val e = row.entry) {
is WorkingMemoryEntry.User -> LiteMessage(LiteRole.USER, e.content.toLiteContents())
is WorkingMemoryEntry.Assistant -> LiteMessage(LiteRole.MODEL, e.content.toLiteContents())
else -> error("unreachable")
}
}
val config = LiteConversationConfig(
systemInstruction = resolvedSystemPrompt.takeIf { it.isNotBlank() },
initialMessages = pastTurns,
tools = tools.map { it.tool },
)
return llm.createConversation(config).also { liteConv = it }
}
private fun emitEvent(event: ProtoEvent) {
events.tryEmit(event)
}
private fun now(): Instant =
Instant.fromEpochMilliseconds(System.currentTimeMillis())
private fun newId(prefix: String): String = "$prefix-${java.util.UUID.randomUUID()}"
private fun encodeArgsJson(arguments: Map<String, Any?>): String {
val el = kotlinx.serialization.json.JsonElement.serializer()
val obj = kotlinx.serialization.json.buildJsonObject {
arguments.forEach { (k, v) -> put(k, v.toJsonElement()) }
}
return kotlinx.serialization.json.Json.encodeToString(el, obj)
}
private fun Any?.toJsonElement(): kotlinx.serialization.json.JsonElement = when (this) {
null -> kotlinx.serialization.json.JsonNull
is Boolean -> kotlinx.serialization.json.JsonPrimitive(this)
is Number -> kotlinx.serialization.json.JsonPrimitive(this)
is String -> kotlinx.serialization.json.JsonPrimitive(this)
is Map<*, *> -> kotlinx.serialization.json.buildJsonObject {
this@toJsonElement.forEach { (k, v) ->
put(k.toString(), v.toJsonElement())
}
}
is List<*> -> kotlinx.serialization.json.JsonArray(this.map { it.toJsonElement() })
else -> kotlinx.serialization.json.JsonPrimitive(toString())
}
companion object {
private const val MAX_TOOL_LOOPS = 16
}
}
internal fun List<Content>.toLiteContents(): List<LiteContentPart> = map { it.toLite() }
internal fun Content.toLite(): LiteContentPart = when (this) {
is Content.Text -> LiteContentPart.Text(body)
is Content.Image -> LiteContentPart.Image(data, mime)
}
private fun Content.toProto(): ProtoContent = when (this) {
is Content.Text -> ProtoContent.Text(body = body)
is Content.Image -> ProtoContent.Image(data = data, mime = mime)
}
internal fun ProtoContent.toStorage(): Content = when (this) {
is ProtoContent.Text -> Content.Text(body)
is ProtoContent.Image -> Content.Image(data, mime)
}
internal fun MessageRecord.toProto(): ProtoMessage = when (this) {
is MessageRecord.UserMessage -> ProtoMessage.UserMessage(
id = id,
date = createdAt,
content = content.map { it.toProto() },
)
is MessageRecord.AssistantMessage -> ProtoMessage.AssistantMessage(
id = id,
date = createdAt,
content = content.map { it.toProto() },
)
is MessageRecord.ToolCall -> ProtoMessage.ToolCall(
id = id,
date = createdAt,
title = toolTitle,
toolName = toolName,
toolArgs = toolArgsJson,
)
is MessageRecord.ToolResult -> ProtoMessage.ToolResult(
id = id,
date = createdAt,
result = result,
)
is MessageRecord.Summary -> ProtoMessage.AssistantMessage(
id = id,
date = createdAt,
content = listOf(ProtoContent.Text(body = text)),
)
is MessageRecord.System -> ProtoMessage.UserMessage(
id = id,
date = createdAt,
content = listOf(ProtoContent.Text(body = text)),
)
}
@@ -0,0 +1,12 @@
package pw.binom.agentik.standalone.agent
import pw.binom.litert.LiteTool
/**
* (имя-как-видит-модель) → [LiteTool].
*
* Имя используется как ключ для матчинга `LiteToolCall.name` (приходящего от LLM)
* с конкретной реализацией тула. Для MCP-адаптеров имя имеет формат `server__tool`,
* чтобы избежать коллизий между разными MCP-серверами.
*/
data class NamedTool(val name: String, val tool: LiteTool)
@@ -0,0 +1,46 @@
package pw.binom.agentik.standalone.llm
import pw.binom.litert.LiteConfig
import pw.binom.litert.LiteLlm
/**
* Factory для движка Google LiteRT-LM на JVM.
*
* `litert-google` опубликован только как Android AAR (с .so внутри), поэтому на JVM
* приходится вручную подгружать JNI-библиотеки LiteRT-LM, прежде чем инстанциировать
* движок. Эта функция:
*
* 1. Резолвит `pw.binom.litert.google.GoogleLiteLlm` через reflection.
* 2. Если процесс уже загрузил нативные библиотеки (`-Djava.library.path`) — успешно
* создаёт движок.
* 3. Если нет — кидает `IllegalStateException` с инструкцией по настройке.
*/
fun googleLiteLlmJvm(config: LiteConfig): LiteLlm {
val cls = try {
Class.forName("pw.binom.litert.google.GoogleLiteLlm")
} catch (e: ClassNotFoundException) {
error(
"litert-google classes are not on the classpath. " +
"Add 'pw.binom.litert:litert-google-android:6' as a runtime dependency " +
"to use the GOOGLE backend on JVM."
)
}
val ctor = cls.constructors.firstOrNull { it.parameterCount == 1 }
?: error("pw.binom.litert.google.GoogleLiteLlm constructor not found")
val engine = try {
ctor.newInstance(config)
} catch (e: UnsatisfiedLinkError) {
throw IllegalStateException(
"LiteRT-LM native libraries are not loaded. " +
"Extract .so/.dylib/.dll from litertlm-android-0.16.1.aar and pass " +
"-Djava.library.path=<dir>, or build :standalone for the androidJvm target.",
e,
)
} catch (e: java.lang.reflect.InvocationTargetException) {
throw e.targetException ?: e
}
@Suppress("UNCHECKED_CAST")
return engine as LiteLlm
}
private fun error(message: String): Nothing = throw IllegalStateException(message)
@@ -0,0 +1,92 @@
package pw.binom.agentik.standalone.llm
import pw.binom.litert.LiteBackend
import pw.binom.litert.LiteConfig
import pw.binom.litert.LiteExperimental
import pw.binom.litert.LiteLlm
import pw.binom.litert.openai.OpenAiConfig as LitertOpenAiConfig
import pw.binom.litert.openai.openAiLiteLlm
data class LlmConfig(
val backend: LlmBackend,
val systemPrompt: String,
val openai: LitertOpenAiConfig? = null,
val google: GoogleConfig? = null,
) {
init {
when (backend) {
LlmBackend.OPENAI -> require(openai != null) { "OPENAI backend requires openai config" }
LlmBackend.GOOGLE -> require(google != null) { "GOOGLE backend requires google config" }
}
}
fun createLlm(): LiteLlm = when (backend) {
LlmBackend.OPENAI -> openAiLiteLlm(checkNotNull(openai))
LlmBackend.GOOGLE -> googleLiteLlmJvm(google!!.toLiteConfig())
}
fun modelInfo(): String = when (backend) {
LlmBackend.OPENAI -> "${checkNotNull(openai).model} @ ${checkNotNull(openai).baseUrl}"
LlmBackend.GOOGLE -> "${checkNotNull(google).modelPath}"
}
companion object {
const val DEFAULT_SYSTEM_PROMPT: String = "Ты полезный ассистент. Отвечай кратко и по делу."
fun fromEnv(env: (String) -> String? = System::getenv): LlmConfig {
val backend = LlmBackend.parse(env("AGENTIK_LLM_BACKEND"))
val systemPromptRaw = env("AGENTIK_SYSTEM_PROMPT")
val systemPrompt = if (systemPromptRaw.isNullOrBlank()) DEFAULT_SYSTEM_PROMPT else systemPromptRaw
return when (backend) {
LlmBackend.OPENAI -> {
val openai = LitertOpenAiConfig(
baseUrl = requireEnv(env, "OPENAI_BASE_URL"),
apiKey = requireEnv(env, "OPENAI_API_KEY"),
model = requireEnv(env, "OPENAI_MODEL"),
)
LlmConfig(backend, systemPrompt, openai = openai)
}
LlmBackend.GOOGLE -> {
val google = GoogleConfig(
modelPath = requireEnv(env, "AGENTIK_GOOGLE_MODEL_PATH"),
cacheDir = env("AGENTIK_GOOGLE_CACHE_DIR"),
threads = env("AGENTIK_GOOGLE_THREADS")?.toInt(),
)
LlmConfig(backend, systemPrompt, google = google)
}
}
}
private fun requireEnv(env: (String) -> String?, name: String): String =
env(name) ?: error("Required env var $name is not set")
}
}
enum class LlmBackend {
OPENAI,
GOOGLE;
companion object {
fun parse(raw: String?): LlmBackend = when (raw?.lowercase()) {
null, "", "openai" -> OPENAI
"google", "litert", "litert-google" -> GOOGLE
else -> error("Unknown LLM backend '$raw', expected 'openai' or 'google'")
}
}
}
data class GoogleConfig(
val modelPath: String,
val cacheDir: String? = null,
val threads: Int? = null,
) {
fun toLiteConfig(): LiteConfig = LiteConfig(
modelPath = modelPath,
cacheDir = cacheDir ?: "",
threads = threads,
backend = LiteBackend.CPU,
visionBackend = null,
audioBackend = null,
experimental = LiteExperimental(false, emptyMap()),
)
}
@@ -0,0 +1,95 @@
package pw.binom.agentik.standalone.mcp
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
/**
* Описание одного MCP-сервера (см. [McpRegistry]).
*
* Два варианта транспорта:
* - [Stdio]: запустить процесс (`command` + `args`), общаться через stdin/stdout
* - [Http]: подключиться к удалённому MCP-серверу по URL (Streamable HTTP)
*/
sealed interface McpServerSpec {
/** Уникальное имя сервера, как его видно в логах и в tool-prefix. */
val name: String
/** stdio: спавним процесс, читаем его stdout, пишем в stdin. */
data class Stdio(
override val name: String,
val command: String,
val args: List<String> = emptyList(),
val env: Map<String, String> = emptyMap(),
) : McpServerSpec
/** http (Streamable HTTP transport): подключаемся к существующему MCP-серверу. */
data class Http(
override val name: String,
val url: String,
val headers: Map<String, String> = emptyMap(),
) : McpServerSpec
}
/**
* Конфигурация MCP-слоя standalone'а.
*
* Парсится из JSON-файла в формате, совместимом с Claude Desktop
* (`mcpServers.{name}.command/args` для stdio, `mcpServers.{name}.url` для http).
*
* Путь к файлу — env `AGENTIK_MCP_CONFIG`. Если не задан или файл не существует — пустой список.
*/
data class McpConfig(
val servers: List<McpServerSpec>,
) {
val isEmpty: Boolean get() = servers.isEmpty()
companion object {
private val json = Json { ignoreUnknownKeys = true }
fun fromEnv(env: (String) -> String? = System::getenv): McpConfig {
val path = env("AGENTIK_MCP_CONFIG")?.takeIf { it.isNotBlank() } ?: return empty()
val file = File(path)
if (!file.exists()) {
System.err.println("[agentik] AGENTIK_MCP_CONFIG points to missing file: $path")
return empty()
}
return fromJson(file.readText())
}
fun empty(): McpConfig = McpConfig(servers = emptyList())
fun fromJson(raw: String): McpConfig {
val root = json.parseToJsonElement(raw).jsonObject
val mcpServers = root["mcpServers"]?.jsonObject ?: return empty()
val servers = mcpServers.entries.mapNotNull { (name, spec) -> parseServer(name, spec.jsonObject) }
return McpConfig(servers)
}
private fun parseServer(name: String, spec: JsonObject): McpServerSpec? {
val url = (spec["url"] as? JsonPrimitive)?.jsonPrimitive?.content
if (url != null) {
val headers = (spec["headers"] as? JsonObject)?.entries
?.associate { (k, v) -> k to (v as JsonPrimitive).jsonPrimitive.content }
?: emptyMap()
return McpServerSpec.Http(name = name, url = url, headers = headers)
}
val command = (spec["command"] as? JsonPrimitive)?.jsonPrimitive?.content
if (command != null) {
val args = (spec["args"] as? kotlinx.serialization.json.JsonArray)
?.map { (it as JsonPrimitive).jsonPrimitive.content }
?: emptyList()
val env = (spec["env"] as? JsonObject)?.entries
?.associate { (k, v) -> k to (v as JsonPrimitive).jsonPrimitive.content }
?: emptyMap()
return McpServerSpec.Stdio(name = name, command = command, args = args, env = env)
}
System.err.println("[agentik] MCP server '$name' has neither 'url' nor 'command' — skipped")
return null
}
}
}
@@ -0,0 +1,259 @@
package pw.binom.agentik.standalone.mcp
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.defaultRequest
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import io.modelcontextprotocol.kotlin.sdk.types.Tool
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
import kotlinx.coroutines.runBlocking
import kotlinx.io.asSink
import kotlinx.io.asSource
import kotlinx.io.buffered
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import pw.binom.agentik.standalone.agent.NamedTool
import pw.binom.litert.LiteTool
import java.util.concurrent.ConcurrentHashMap
/**
* Реестр подключённых MCP-серверов.
*
* На старте подключается ко всем [McpServerSpec] из [McpConfig], у каждого запрашивает
* список tools и оборачивает их в [LiteTool]-адаптеры ([McpLiteToolAdapter]). Все
* адаптеры собираются в [allTools], который `ChatConversation` подмешивает в
* [pw.binom.litert.LiteConversationConfig.tools].
*
* [close] убивает stdio-процессы и закрывает HTTP-клиент.
*/
class McpRegistry(
private val servers: List<McpServerSpec>,
private val httpClient: HttpClient = defaultHttpClient(),
private val clientName: String = "agentik",
private val clientVersion: String = "1.0.0",
) : AutoCloseable {
private val connected: MutableMap<String, ConnectedServer> = ConcurrentHashMap()
@Volatile private var closed = false
/** Все [LiteTool] со всех подключённых серверов. */
val allTools: List<LiteTool> by lazy {
connected.values.flatMap { it.tools }
}
/** Все [LiteTool] с именами (server__tool), которые видит LLM. */
val namedTools: List<NamedTool> by lazy {
allTools.filterIsInstance<McpLiteToolAdapter>().map { NamedTool(it.fullName, it) }
}
/** Количество успешно подключённых серверов. */
val connectedServerCount: Int get() = connected.size
init {
if (servers.isNotEmpty()) {
for (spec in servers) {
runCatching {
val server = connectOne(spec)
connected[spec.name] = server
}.onFailure { e ->
System.err.println("[agentik] MCP server '${spec.name}' failed to connect: ${e.message}")
}
}
System.err.println("[agentik] MCP registry: ${connected.size}/${servers.size} servers connected, ${allTools.size} tools total")
}
}
private fun connectOne(spec: McpServerSpec): ConnectedServer {
var ownedProcess: Process? = null
val transport: Transport = when (spec) {
is McpServerSpec.Stdio -> {
val cmd = (listOf(spec.command) + spec.args).joinToString(" ")
System.err.println("[agentik] MCP stdio '$spec.name': $cmd")
val pb = ProcessBuilder(buildList { add(spec.command); addAll(spec.args) })
.redirectErrorStream(false)
spec.env.forEach { (k, v) -> pb.environment()[k] = v }
val proc = pb.start()
ownedProcess = proc
StdioClientTransport(
input = proc.inputStream.asSource().buffered(),
output = proc.outputStream.asSink().buffered(),
error = proc.errorStream.asSource().buffered(),
)
}
is McpServerSpec.Http -> {
System.err.println("[agentik] MCP http '$spec.name': ${spec.url}")
StreamableHttpClientTransport(
client = httpClient.config {
if (spec.headers.isNotEmpty()) {
defaultRequest {
spec.headers.forEach { (k, v) ->
headers { append(k, v) }
}
}
}
},
url = spec.url,
)
}
}
val client = Client(
clientInfo = Implementation(name = clientName, version = clientVersion, title = null, websiteUrl = null, icons = emptyList()),
)
runBlocking { client.connect(transport) }
val mcpTools = runBlocking { client.listTools().tools }
val liteTools = mcpTools.map { tool -> McpLiteToolAdapter(spec.name, tool, client) }
return ConnectedServer(spec, client, transport, ownedProcess, liteTools)
}
override fun close() {
if (closed) return
closed = true
connected.values.forEach { server ->
runCatching { runBlocking { server.transport.close() } }
server.ownedProcess?.let { runCatching { it.destroyForcibly() } }
}
connected.clear()
runCatching { httpClient.close() }
}
private data class ConnectedServer(
val spec: McpServerSpec,
val client: Client,
val transport: Transport,
val ownedProcess: Process?,
val tools: List<LiteTool>,
)
companion object {
private fun defaultHttpClient(): HttpClient = HttpClient(CIO)
fun fromConfig(config: McpConfig): McpRegistry =
McpRegistry(servers = config.servers)
}
}
/**
* Адаптер MCP [Tool] → [pw.binom.litert.LiteTool].
*
* Имя тула префиксуется именем сервера через `__`, чтобы избежать коллизий
* между MCP-серверами (например, оба могут иметь tool `search`).
*
* [describe] сериализует tool в JSON-дескриптор в формате, который litert-openai
* и litert-google принимают как function-calling definition:
*
* ```json
* {
* "type": "function",
* "function": {
* "name": "<server>__<tool>",
* "description": "...",
* "parameters": { "type": "object", "properties": {...}, "required": [...] }
* }
* }
* ```
*
* [invoke] вызывает [Client.callTool] по оригинальному (непрефиксованному) имени тула
* на нужном MCP-сервере и схлопывает [CallToolResult] в плоский текст:
* для каждого блока контента возвращается либо `text`, либо JSON-представление.
* Если `isError == true` — текст префиксуется `[tool error]`.
*/
internal class McpLiteToolAdapter(
private val serverName: String,
private val tool: Tool,
private val client: Client,
) : LiteTool {
internal val fullName: String = "${serverName}__${tool.name}"
override fun describe(): String =
buildJsonObject {
put("type", "function")
put("function", buildJsonObject {
put("name", fullName)
put("description", tool.description ?: "")
put("parameters", tool.inputSchema.toJsonSchema())
})
}.toString()
override fun invoke(arguments: String): String {
val argsMap = parseArgsJson(arguments, tool.name)
val result: CallToolResult = runBlocking { client.callTool(tool.name, argsMap) }
return renderResult(result)
}
private fun renderResult(result: CallToolResult): String {
val isError = result.isError == true
val parts = result.content.map { block ->
when (block) {
is TextContent -> block.text
else -> Json.encodeToString(JsonElement.serializer(), JsonPrimitive(block.toString()))
}
}
val text = parts.joinToString("\n").ifEmpty { "[]" }
return if (isError) "[tool error] $text" else text
}
private fun ToolSchema?.toJsonSchema(): JsonElement {
if (this == null) return buildJsonObject { put("type", "object") }
val props = this.properties ?: buildJsonObject { }
val reqs = this.required ?: emptyList()
return buildJsonObject {
put("type", this@toJsonSchema.type.ifEmpty { "object" })
put("properties", props)
put("required", JsonArray(reqs.map { JsonPrimitive(it) }))
}
}
companion object {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private fun parseArgsJson(raw: String, toolName: String): Map<String, Any?> {
if (raw.isBlank()) return emptyMap()
return try {
val parsed = json.parseToJsonElement(raw)
if (parsed !is JsonObject) emptyMap() else parsed.toAnyMap()
} catch (e: Throwable) {
System.err.println("[agentik] MCP tool '$toolName' got invalid args JSON: ${e.message}")
emptyMap()
}
}
private fun JsonObject.toAnyMap(): Map<String, Any?> =
entries.associate { (k, v) -> k to jsonElementToAny(v) }
/**
* Преобразует [JsonPrimitive] в типизированное значение (Boolean/Int/Long/Float/Double),
* и только в крайнем случае — в String. Без этого MCP-сервер получает все аргументы
* как строки и отвергает их по JSON-schema (например, `max_length:500` → `"500"` →
* «'500' is not of type 'integer'»).
*/
private fun jsonElementToAny(el: JsonElement): Any? = when (el) {
is JsonPrimitive ->
el.booleanOrNull
?: el.intOrNull
?: el.longOrNull
?: el.doubleOrNull
?: if (el.isString) el.content else el.content
is JsonArray -> el.map { jsonElementToAny(it) }
is JsonObject -> el.toAnyMap()
else -> null
}
}
}
@@ -0,0 +1,80 @@
package pw.binom.agentik.standalone.persistence.sqlite
import kotlin.time.Instant
import pw.binom.agentik.standalone.persistence.ConversationRecord
import pw.binom.agentik.standalone.persistence.ConversationStore
/**
* SQLite-реализация [ConversationStore].
*/
class SqliteConversationStore(private val db: AgentikDatabase) : ConversationStore {
private val q get() = db.conversationQueries
override suspend fun upsert(record: ConversationRecord) {
// SQLite-конфликт по PRIMARY KEY → сначала пробуем insert, при ошибке → update
val existing = q.getById(record.id).executeAsOneOrNull()
if (existing == null) {
q.insert(
id = record.id,
title = record.title,
is_temporal = if (record.isTemporal) 1L else 0L,
created_at = record.createdAt.toEpochMilliseconds(),
updated_at = record.updatedAt.toEpochMilliseconds(),
)
} else {
q.update(
title = record.title,
is_temporal = if (record.isTemporal) 1L else 0L,
updated_at = record.updatedAt.toEpochMilliseconds(),
id = record.id,
)
}
}
override suspend fun get(id: String): ConversationRecord? {
val row = q.getById(id).executeAsOneOrNull() ?: return null
return row.toRecord()
}
override suspend fun delete(id: String): Boolean {
// Проверяем существование ДО удаления — иначе пустой `deleteById` вернёт
// «успех», и get() == null будет true, хотя диалога и не было.
val existed = q.getById(id).executeAsOneOrNull() != null
if (!existed) return false
db.transaction {
db.messageQueries.deleteByConversation(id)
db.workingMemoryQueries.clearByConversation(id)
q.deleteById(id)
}
return true
}
override suspend fun list(offset: Int, limit: Int): List<ConversationRecord> =
q.list(limit = limit.toLong(), offset = offset.toLong())
.executeAsList()
.map { it.toRecord() }
override suspend fun rename(id: String, title: String?): Instant? {
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
q.rename(title = title, updated_at = now.toEpochMilliseconds(), id = id)
val ts = q.getUpdatedAt(id).executeAsOneOrNull() ?: return null
return Instant.fromEpochMilliseconds(ts)
}
override suspend fun touch(id: String, now: Instant) {
q.touch(updated_at = now.toEpochMilliseconds(), id = id)
}
override fun close() {
// driver закрывается во внешнем SqliteStores
}
}
private fun Conversation.toRecord(): ConversationRecord = ConversationRecord(
id = id,
title = title,
isTemporal = is_temporal != 0L,
createdAt = Instant.fromEpochMilliseconds(created_at),
updatedAt = Instant.fromEpochMilliseconds(updated_at),
)
@@ -0,0 +1,115 @@
package pw.binom.agentik.standalone.persistence.sqlite
import kotlinx.serialization.json.Json
import pw.binom.agentik.standalone.persistence.MessageRecord
import pw.binom.agentik.standalone.persistence.MessageStore
import pw.binom.agentik.standalone.persistence.decodeBodyPayload
import pw.binom.agentik.standalone.persistence.encodeBodyPayload
import kotlin.time.Instant
/**
* SQLite-реализация [MessageStore] (append-only audit log).
*
* `payloadJson` хранит JSON-сериализованные kind-specific поля. Для
* `user`/`assistant` это `List<Content>` (см. [encodeBodyPayload]).
* Для `tool_call`/`tool_result` payload хранит JSON-объект
* (см. [CallPayload]/[ResultPayload]).
*
* SQLDelight сохраняет snake_case в сгенерированной data class (`Message`),
* поэтому обращаемся через `conversation_id`, `payload_json`, `created_at`.
*/
class SqliteMessageStore(private val db: AgentikDatabase) : MessageStore {
private val q get() = db.messageQueries
override suspend fun append(record: MessageRecord) {
val (kind, payload) = encodeRecord(record)
q.insert(
conversation_id = record.conversationId,
kind = kind,
payload_json = payload,
created_at = record.createdAt.toEpochMilliseconds(),
id = record.id,
)
}
override suspend fun list(
conversationId: String,
after: Instant,
offset: Int,
limit: Int,
): List<MessageRecord> = q.listAfter(
conversation_id = conversationId,
created_at = after.toEpochMilliseconds(),
limit = limit.toLong(),
offset = offset.toLong(),
).executeAsList().map { it.toRecord() }
override suspend fun listAll(conversationId: String): List<MessageRecord> =
q.listByConversationAll(conversation_id = conversationId).executeAsList().map { it.toRecord() }
override fun close() {}
}
private fun encodeRecord(record: MessageRecord): Pair<String, String> = when (record) {
is MessageRecord.UserMessage -> "user" to encodeBodyPayload(record.content)
is MessageRecord.AssistantMessage -> "assistant" to encodeBodyPayload(record.content)
is MessageRecord.ToolCall -> "tool_call" to Json.encodeToString(
CallPayload.serializer(),
CallPayload(name = record.toolName, title = record.toolTitle, argsJson = record.toolArgsJson),
)
is MessageRecord.ToolResult -> "tool_result" to Json.encodeToString(
ResultPayload.serializer(),
ResultPayload(toolCallId = record.toolCallId, result = record.result),
)
is MessageRecord.Summary,
is MessageRecord.System -> error("Summary/System — synthetic, cannot append to audit log")
}
@kotlinx.serialization.Serializable
internal data class CallPayload(val name: String, val title: String?, val argsJson: String)
@kotlinx.serialization.Serializable
internal data class ResultPayload(val toolCallId: String, val result: String?)
private fun Message.toRecord(): MessageRecord {
val id = id
val convId = conversation_id
val createdAt = Instant.fromEpochMilliseconds(created_at)
return when (kind) {
"user" -> MessageRecord.UserMessage(
id = id,
conversationId = convId,
content = decodeBodyPayload(payload_json),
createdAt = createdAt,
)
"assistant" -> MessageRecord.AssistantMessage(
id = id,
conversationId = convId,
content = decodeBodyPayload(payload_json),
createdAt = createdAt,
)
"tool_call" -> {
val p = Json.decodeFromString(CallPayload.serializer(), payload_json)
MessageRecord.ToolCall(
id = id,
conversationId = convId,
toolName = p.name,
toolTitle = p.title,
toolArgsJson = p.argsJson,
createdAt = createdAt,
)
}
"tool_result" -> {
val p = Json.decodeFromString(ResultPayload.serializer(), payload_json)
MessageRecord.ToolResult(
id = id,
conversationId = convId,
toolCallId = p.toolCallId,
result = p.result,
createdAt = createdAt,
)
}
else -> error("Unknown message kind in audit log: $kind")
}
}
@@ -0,0 +1,73 @@
package pw.binom.agentik.standalone.persistence.sqlite
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import pw.binom.agentik.standalone.persistence.ConversationStore
import pw.binom.agentik.standalone.persistence.MessageStore
import pw.binom.agentik.standalone.persistence.WorkingMemoryStore
/**
* Корневой объект SQLite-слоя: держит [SqlDriver] и три [WorkingMemoryStore]/[MessageStore]/[ConversationStore].
* Закрывается вместе с приложением.
*/
class SqliteStores private constructor(
val driver: SqlDriver,
val conversations: ConversationStore,
val messages: MessageStore,
val workingMemory: WorkingMemoryStore,
) : AutoCloseable {
override fun close() {
conversations.close()
messages.close()
workingMemory.close()
driver.close()
}
companion object {
/** Открыть/создать БД по пути `dbPath` (например, `"./agentik.db"` или абсолютный путь). */
fun open(dbPath: String): SqliteStores {
val driver = JdbcSqliteDriver("jdbc:sqlite:$dbPath")
createSchema(driver)
val db = AgentikDatabase(driver)
return SqliteStores(
driver = driver,
conversations = SqliteConversationStore(db),
messages = SqliteMessageStore(db),
workingMemory = SqliteWorkingMemoryStore(db),
)
}
/** Открыть/создать БД в памяти (для тестов). */
fun inMemory(): SqliteStores {
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
createSchema(driver)
val db = AgentikDatabase(driver)
return SqliteStores(
driver = driver,
conversations = SqliteConversationStore(db),
messages = SqliteMessageStore(db),
workingMemory = SqliteWorkingMemoryStore(db),
)
}
private fun createSchema(driver: SqlDriver) {
// Если таблица `conversation` уже есть — БД уже инициализирована,
// просто пропускаем create (иначе CREATE TABLE упадёт на дубликате).
val existing = driver.executeQuery(
identifier = null,
sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='conversation'",
mapper = { cursor ->
QueryResult.Value(
if (cursor.next().value) cursor.getString(0) else null,
)
},
parameters = 0,
).value
if (existing != null) return
AgentikDatabase.Schema.create(driver)
}
}
}
@@ -0,0 +1,70 @@
package pw.binom.agentik.standalone.persistence.sqlite
import kotlinx.serialization.json.Json
import pw.binom.agentik.standalone.persistence.WorkingMemoryEntry
import pw.binom.agentik.standalone.persistence.WorkingMemoryRow
import pw.binom.agentik.standalone.persistence.WorkingMemoryStore
import kotlin.time.Instant
/**
* SQLite-реализация [WorkingMemoryStore] (мутируемый LLM-контекст).
*
* Строки упорядочены по `order_idx ASC`. Compact — атомарная операция:
* DELETE строк `>= dropFromOrderIdx` (без summary в v1).
*/
class SqliteWorkingMemoryStore(private val db: AgentikDatabase) : WorkingMemoryStore {
private val q get() = db.workingMemoryQueries
private val json = Json { ignoreUnknownKeys = true }
override suspend fun append(conversationId: String, entry: WorkingMemoryEntry, now: Instant) {
val newIdx = (q.maxOrderIdx(conversationId).executeAsOne()) + 1
q.insert(
id = newId(),
conversation_id = conversationId,
order_idx = newIdx,
source_message_id = entry.sourceMessageId,
kind = entryKind(entry),
payload_json = json.encodeToString(WorkingMemoryEntry.serializer(), entry),
created_at = now.toEpochMilliseconds(),
)
}
override suspend fun list(conversationId: String): List<WorkingMemoryRow> =
q.listByConversation(conversationId).executeAsList().map { it.toRow() }
override suspend fun clear(conversationId: String) {
q.clearByConversation(conversationId)
}
override suspend fun compact(dropFromOrderIdx: Long, conversationId: String): Long {
var newMax = 0L
db.transaction {
q.compactDelete(conversation_id = conversationId, order_idx = dropFromOrderIdx)
newMax = q.maxOrderIdx(conversationId).executeAsOne()
}
return newMax
}
override fun close() {}
}
private fun entryKind(e: WorkingMemoryEntry): String = when (e) {
is WorkingMemoryEntry.System -> "system"
is WorkingMemoryEntry.User -> "user"
is WorkingMemoryEntry.Assistant -> "assistant"
}
private fun Working_memory.toRow(): WorkingMemoryRow {
val entry = Json.decodeFromString(WorkingMemoryEntry.serializer(), payload_json)
return WorkingMemoryRow(
id = id,
conversationId = conversation_id,
orderIdx = order_idx,
sourceMessageId = source_message_id,
entry = entry,
createdAt = Instant.fromEpochMilliseconds(created_at),
)
}
private fun newId(): String = "wm-${java.util.UUID.randomUUID()}"
@@ -0,0 +1,42 @@
CREATE TABLE conversation (
id TEXT NOT NULL PRIMARY KEY,
title TEXT,
is_temporal INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_conv_updated ON conversation(updated_at DESC);
insert:
INSERT INTO conversation (id, title, is_temporal, created_at, updated_at)
VALUES (?, ?, ?, ?, ?);
update:
UPDATE conversation SET title = ?, is_temporal = ?, updated_at = ?
WHERE id = ?;
getById:
SELECT * FROM conversation WHERE id = ?;
list:
SELECT * FROM conversation
WHERE is_temporal = 0
ORDER BY updated_at DESC
LIMIT :limit OFFSET :offset;
listAll:
SELECT * FROM conversation
ORDER BY updated_at DESC;
deleteById:
DELETE FROM conversation WHERE id = ?;
rename:
UPDATE conversation SET title = ?, updated_at = ? WHERE id = ?;
getUpdatedAt:
SELECT updated_at FROM conversation WHERE id = ?;
touch:
UPDATE conversation SET updated_at = ? WHERE id = ?;
@@ -0,0 +1,33 @@
CREATE TABLE message (
id TEXT NOT NULL PRIMARY KEY,
conversation_id TEXT NOT NULL,
kind TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX idx_msg_conv ON message(conversation_id, created_at);
insert:
INSERT INTO message (id, conversation_id, kind, payload_json, created_at)
VALUES (?, ?, ?, ?, ?);
listByConversation:
SELECT * FROM message
WHERE conversation_id = ?
ORDER BY created_at ASC, id ASC
LIMIT :limit OFFSET :offset;
listByConversationAll:
SELECT * FROM message
WHERE conversation_id = ?
ORDER BY created_at ASC, id ASC;
listAfter:
SELECT * FROM message
WHERE conversation_id = ? AND created_at > ?
ORDER BY created_at ASC, id ASC
LIMIT :limit OFFSET :offset;
deleteByConversation:
DELETE FROM message WHERE conversation_id = ?;
@@ -0,0 +1,34 @@
CREATE TABLE working_memory (
id TEXT NOT NULL PRIMARY KEY,
conversation_id TEXT NOT NULL,
order_idx INTEGER NOT NULL,
source_message_id TEXT,
kind TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX idx_wm_unique ON working_memory(conversation_id, order_idx);
CREATE INDEX idx_wm_conv ON working_memory(conversation_id, order_idx);
insert:
INSERT INTO working_memory (id, conversation_id, order_idx, source_message_id, kind, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?);
listByConversation:
SELECT * FROM working_memory
WHERE conversation_id = ?
ORDER BY order_idx ASC;
clearByConversation:
DELETE FROM working_memory WHERE conversation_id = ?;
maxOrderIdx:
SELECT COALESCE(MAX(order_idx), 0) FROM working_memory WHERE conversation_id = ?;
-- Atomic compact: delete rows >= dropFromOrderIdx and insert summary.
-- Caller supplies summaryId, summaryText, now epoch millis, and the new summary
-- gets order_idx = current max (after delete = before max).
compactDelete:
DELETE FROM working_memory
WHERE conversation_id = ? AND order_idx >= ?;
@@ -0,0 +1,520 @@
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.proto.AgentEvent
import pw.binom.agentik.proto.Content
import pw.binom.agentik.proto.Event as ProtoEvent
import pw.binom.agentik.standalone.llm.LlmBackend
import pw.binom.agentik.standalone.llm.LlmConfig
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
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.litert.openai.OpenAiConfig
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.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
class ChatAgentTest {
private lateinit var stores: SqliteStores
private lateinit var fakeLlm: FakeLiteLlm
@BeforeTest
fun setup() {
stores = SqliteStores.inMemory()
fakeLlm = FakeLiteLlm()
}
@AfterTest
fun tearDown() {
stores.close()
}
private fun newAgent(
stores: SqliteStores = this.stores,
llm: LiteLlm = this.fakeLlm,
tools: List<NamedTool> = emptyList(),
): ChatAgent = ChatAgent(
id = "agentik",
stores = stores,
llm = llm,
llmConfig = LlmConfig(
backend = LlmBackend.OPENAI,
systemPrompt = "be brief",
openai = OpenAiConfig(baseUrl = "http://test", apiKey = "test", model = "test"),
),
tools = tools,
)
@Test
fun `createConversation seeds system prompt into working memory`() = runTest {
val agent = newAgent()
val conv = agent.createConversation(temp = false) as ChatConversation
val wm = stores.workingMemory.list(conv.id)
assertEquals(1, wm.size)
val first = wm[0]
assertIs<pw.binom.agentik.standalone.persistence.WorkingMemoryEntry.System>(first.entry)
assertEquals("be brief", first.entry.text)
}
@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
// добавим сообщение, чтобы потом убедиться, что каскад сработал
stores.messages.append(
pw.binom.agentik.standalone.persistence.MessageRecord.UserMessage(
id = "m1",
conversationId = id,
content = listOf(pw.binom.agentik.standalone.persistence.Content.Text("hi")),
createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000),
),
)
assertTrue(agent.deleteConversation(id))
assertNull(agent.getConversation(id))
assertNull(stores.conversations.get(id))
assertEquals(emptyList(), stores.messages.listAll(id))
}
@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 = stores.messages.listAll(conv.id)
assertEquals(2, msgs.size)
assertEquals("hi", (msgs[0] as pw.binom.agentik.standalone.persistence.MessageRecord.UserMessage).content.let {
(it[0] as pw.binom.agentik.standalone.persistence.Content.Text).body
})
assertEquals("hello back", (msgs[1] as pw.binom.agentik.standalone.persistence.MessageRecord.AssistantMessage).content.let {
(it[0] as pw.binom.agentik.standalone.persistence.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")))
// Первая беседа должна иметь только свою систему + 1 user + 1 assistant
val wm1 = stores.workingMemory.list(conv1.id)
assertEquals(3, wm1.size)
// Вторая беседа — только своё
val wm2 = stores.workingMemory.list(conv2.id)
assertEquals(3, 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 in single LiteConversation`() = runTest {
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 = [system, user, assistant]
assertEquals(3, stores.workingMemory.list(conv.id).size)
fakeLlm.reply = "second reply"
conv.send(listOf(Content.Text("second user")))
// второй turn: WM должен вырасти до [system, user, assistant, user, assistant]
val wm = stores.workingMemory.list(conv.id)
assertEquals(5, wm.size)
// Длинно-живущий LiteConversation: один на ChatConversation, история
// накапливается через sendStreamContents, без пересоздания.
assertEquals(1, fakeLlm.conversations.size)
val history = fakeLlm.conversations[0].history
assertEquals(4, history.size)
assertEquals("first user", history[0].text)
assertEquals(LiteRole.USER, history[0].role)
assertEquals("first reply", history[1].text)
assertEquals(LiteRole.MODEL, history[1].role)
assertEquals("second user", history[2].text)
assertEquals(LiteRole.USER, history[2].role)
assertEquals("second reply", history[3].text)
assertEquals(LiteRole.MODEL, history[3].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 `interrupt cancels active send`() = runTest {
val agent = newAgent()
fakeLlm.slow = true
val conv = agent.createConversation(temp = false)
val sendJob = launch {
try {
conv.send(listOf(Content.Text("hi")))
} catch (_: kotlinx.coroutines.CancellationException) {
// ok
}
}
// ждём, пока корутина дойдёт до sendStreamContents и повиснет на slow-эмиссии
delay(200)
conv.interrupt()
sendJob.join()
// user сообщение в audit должно быть, assistant — нет (был отменён)
val msgs = stores.messages.listAll(conv.id)
assertEquals(1, msgs.size)
assertIs<pw.binom.agentik.standalone.persistence.MessageRecord.UserMessage>(msgs[0])
}
@Test
fun `temp conversation is not persisted across agent instances`() = runTest {
// Поднимаем file-backed БД, создаём temp-беседу
stores.close()
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
stores = SqliteStores.open(dbPath)
val agent1 = ChatAgent(
id = "agentik",
stores = stores,
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-беседа не должна пережить рестарт
stores.close()
stores = SqliteStores.open(dbPath)
val agent2 = ChatAgent(
id = "agentik",
stores = stores,
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 {
stores.close()
val dbPath = (System.getProperty("java.io.tmpdir") + "/agentik-test-${System.nanoTime()}.db")
stores = SqliteStores.open(dbPath)
val agent1 = ChatAgent(
id = "agentik",
stores = stores,
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
stores.close()
stores = SqliteStores.open(dbPath)
val agent2 = ChatAgent(
id = "agentik",
stores = stores,
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? = 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")))
assertEquals(1, toolLlm.toolCallCount, "expected one round-trip through LiteConversation")
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(Instant.DISTANT_PAST).collect { events.add(it) }
}
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 FakeLiteLlm : LiteLlm {
override val backendName: String = "fake"
override val capabilities: pw.binom.litert.LiteCapabilities? = null
var reply: String = ""
var rememberHistory: Boolean = false
var slow: Boolean = false
var lastConfig: LiteConversationConfig? = null
var lastContents: List<LiteContentPart>? = null
val conversations = mutableListOf<FakeLiteConversation>()
override fun isInitialized(): Boolean = true
override fun createConversation(config: LiteConversationConfig): LiteConversation {
lastConfig = config
val conv = FakeLiteConversation(this, config)
conversations.add(conv)
return conv
}
override fun infer(request: pw.binom.litert.LiteRequest): String {
throw UnsupportedOperationException("not used in test")
}
override fun inferStream(request: pw.binom.litert.LiteRequest): Flow<LiteDelta> {
throw UnsupportedOperationException("not used in test")
}
override fun close() {}
fun emit(text: String, sink: FakeLiteConversation): List<LiteDelta> {
// Эмулируем один-два фрагмента + done
return listOf(
LiteDelta(text = text.substring(0, text.length / 2), isDone = false),
LiteDelta(text = text.substring(text.length / 2), isDone = true),
)
}
}
private class FakeLiteConversation(
private val parent: FakeLiteLlm,
config: LiteConversationConfig,
) : LiteConversation {
val initialMessages: List<LiteMessage> = config.initialMessages
private val mutableHistory: MutableList<LiteMessage> = config.initialMessages.toMutableList()
override val history: List<LiteMessage>
get() = mutableHistory.toList()
override fun sendStream(prompt: String): Flow<LiteDelta> =
sendStreamContents(listOf(LiteContentPart.Text(prompt)))
override fun sendStreamContents(contents: List<LiteContentPart>): Flow<LiteDelta> {
parent.lastContents = contents
mutableHistory.add(LiteMessage(LiteRole.USER, contents))
if (parent.slow) {
return kotlinx.coroutines.flow.flow {
emit(LiteDelta(text = parent.reply.substring(0, parent.reply.length / 2)))
kotlinx.coroutines.delay(10_000)
emit(LiteDelta(text = parent.reply.substring(parent.reply.length / 2), isDone = true))
mutableHistory.add(LiteMessage.model(parent.reply))
}
}
val first = parent.reply.substring(0, parent.reply.length / 2)
val second = parent.reply.substring(parent.reply.length / 2)
return flowOf(
LiteDelta(text = first),
LiteDelta(text = second, isDone = true),
).also {
mutableHistory.add(LiteMessage.model(parent.reply))
}
}
override fun send(prompt: String): String = parent.reply
override fun sendContents(contents: List<LiteContentPart>): String = parent.reply
override fun cancel() {}
override fun tokenCount(): Int = history.size
override fun addToolResult(callId: String?, name: String, result: String) { error("not used") }
override fun close() {}
}
/**
* LiteLlm который имитирует tool-loop:
* - первый send → LiteDelta(toolCalls=[LiteToolCall("echo", {"x":"hi"})], isDone=true)
* - после addToolResult → продолжение send отдаёт LiteDelta(text="final reply", isDone=true)
*/
private class ToolLoopFakeLiteLlm : LiteLlm {
override val backendName: String = "fake-tool"
override val capabilities: pw.binom.litert.LiteCapabilities? = null
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))
return if (toolCallCount == 0) {
toolCallCount++
flowOf(
LiteDelta(
text = "",
isDone = true,
toolCalls = listOf(LiteToolCall(name = "echo", arguments = mapOf("x" to "hi"))),
),
)
} else {
val reply = "final reply"
finalReplyEmitted = reply
flowOf(LiteDelta(text = reply, isDone = true))
}
}
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) {
System.err.println("[agentik] DEBUG ToolLoopFakeLiteLlm.addToolResult callId=$callId name=$name result=$result")
lastToolResult = result
}
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() {}
}
@@ -0,0 +1,90 @@
package pw.binom.agentik.standalone.llm
import pw.binom.litert.openai.OpenAiConfig
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class LlmConfigTest {
@Test
fun `fromEnv — happy path`() {
val cfg = LlmConfig.fromEnv { name ->
when (name) {
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
"OPENAI_API_KEY" -> "sk-test"
"OPENAI_MODEL" -> "gpt-4o-mini"
"AGENTIK_SYSTEM_PROMPT" -> "be brief"
else -> null
}
}
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 = LlmConfig.fromEnv { name ->
when (name) {
"OPENAI_BASE_URL" -> "https://api.openai.com/v1"
"OPENAI_API_KEY" -> "sk-test"
"OPENAI_MODEL" -> "gpt-4o-mini"
else -> null
}
}
assertEquals(LlmConfig.DEFAULT_SYSTEM_PROMPT, cfg.systemPrompt)
}
@Test
fun `fromEnv — missing base url throws`() {
assertFailsWith<IllegalStateException> {
LlmConfig.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> {
LlmConfig.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> {
LlmConfig.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 = LlmConfig.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
}
}
assertEquals(LlmConfig.DEFAULT_SYSTEM_PROMPT, cfg.systemPrompt)
}
}
@@ -0,0 +1,88 @@
package pw.binom.agentik.standalone.mcp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
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)
}
}
@@ -0,0 +1,48 @@
package pw.binom.agentik.standalone.mcp
import pw.binom.agentik.standalone.agent.NamedTool
import pw.binom.litert.LiteTool
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertSame
import kotlin.test.assertTrue
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)
}
}
@@ -0,0 +1,217 @@
package pw.binom.agentik.standalone.persistence
import kotlinx.coroutines.test.runTest
import pw.binom.agentik.standalone.persistence.sqlite.SqliteStores
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
class PersistenceTest {
private lateinit var stores: SqliteStores
@BeforeTest
fun setup() {
stores = SqliteStores.inMemory()
}
@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.listAll("c1").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.listAll("c1"))
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.listAll("c1")
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.System(text = "you are a bot"),
now = t0,
)
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(3, list.size)
assertTrue(list[0].entry is WorkingMemoryEntry.System)
assertTrue(list[1].entry is WorkingMemoryEntry.User)
assertTrue(list[2].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 `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.listAll("c1")
val image = (all[0] as MessageRecord.UserMessage).content[0] as Content.Image
assertEquals("image/png", image.mime)
assertTrue(bytes.contentEquals(image.data))
}
}