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.
8.3 KiB
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. Как вписываемся (принципы)
threadId= наша сессия. ДержимSessionStore[threadId]на сервере даже при stateless-сервере AGUI: на каждом ходе реконсилируемinput.messagesс сохранёнными и дописываем новые. Это даёт «настоящие сессии» с историей.runId= ход. ОдинRunAgentInput→ один цикл ядра → один поток событий.- Ядро говорит на AGUI-событиях — это и есть наш протокол вывода; фасады не переопределяют его.
- LLM — за интерфейсом. Ядро не знает, какая LLM. Точка входа
LlmClient.complete(...). - Тулы: внутренний реестр + MCP.
ToolRegistryобъединяет «родные» тулы и тулы MCP-серверов (через bridge). LLM вызывает их; ядро исполняет и возвращаетTOOL_CALL_RESULT. - А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:
text = request.parts.filterIsInstance<TextPart>().join("").RunAgentInput(threadId = contextId ?: new, runId = randomRunId(), messages = [Message(USER, text)]).events = engine.run(input)(синхронно,runBlocking { ...toList() }).answer = events.filterIsInstance<TextMessageContentEvent>().join { it.delta }.- вернуть
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. Открытые решения (нужны решения)
- Персист сессий — in-memory или на диск/БД (сейчас in-memory).
- Конфигурация — MCP-серверы, LLM endpoint/ключ, системный промпт: env / yaml / файл (сейчас env-заготовки:
AGENTIK_A2A_AGENTS). - Ограничения —
maxToolRounds, таймауты LLM/тулов, лимиты размера. - Аутентификация — Bearer на обоих фасадах (а2a/agui принимают
token), пока не включена. - Структура модулей — держать всё в
standalone(по пакетам) или вынестиcore/mcpв отдельные подмодули (для переиспользования). - Поведение сессии — «клиент источник правды» (replace) vs «сервер источник правды» (append) при реконсилии
messages.