Files
agentik/docs/ARCHITECTURE.md
T
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

8.3 KiB
Raw Blame History

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.