
Всем привет! С вами Анна Жаркова, руководитель мобильной практики ГК Юзтех. Что ж, за последние полгода мир разработки и мир ИИ скакнули и ушли далеко вперед. Теперь знания работы с агентами, умение написать не только правильный промт, но и собственные скиллы (навыки) для этих агентов, готовить свои MCP для погружения в контекст задачи, проекта, становятся не только полезными, но и обязательными для разработчиков и IT-специалистов. Уже многие используют как специальные IDE с ИИ-агентами (Claude, Cursor, Windsurf и т.п.), так и встраиваемые в привычные VsCode и AndroidStudio в виде плагинов. Можно не ограничиваться готовым настраиваемым функционалом, а пойти дальше и написать свой собственный агент. И сегодня мы поговорим про такое решение, использование специального фреймворка от JetBrains Koog для разработки своих агентов. С его помощью мы создадим агент для генерации простых KMP приложений и кросс-платформенных задач и подключим к плагину Continue Dev.
Небольшой спойлер: сам агент был написан при участии Cursor, и про нюансы его создания читайте в конце статьи.
А пока мы поговорим:
-
что такое Koog
-
что входит в собственный агент
-
как подготовить tools и MCP
-
как подключить наш MCP к Continue Dev плагину.
Koog — это фреймворк на базе Kotlin, разработанный JetBrains для создания и запуска AI-агентов. Его основная идея заключается в том, чтобы дать разработчикам возможность строить интеллектуальных агентов, используя Kotlin. Это означает, что вы можете создавать сложные AI-решения, оставаясь в привычной и любимой среде Kotlin, без необходимости изучать новые языки или парадигмы для работы с AI. Что мне понравилось: Koog можно использовать как компонент вашего кроссплатформенного KMP приложения. Вы можете в одном проекте создать и MCP сервер, и агента, и клиента для проверки. Также вы можете встроить агента на Koog в свое приложение.
У Koog есть и другие особенности:
-
Интеграция с MCP (Model Context Protocol): позволяет эффективно управлять моделями и их контекстом.
-
Возможности встраивания (Embedding capabilities) для семантического поиска и извлечения знаний, что критически важно для RAG (Retrieval Augmented Generation) систем.
-
Создание пользовательских инструментов, которые могут взаимодействовать с внешними системами и API. Это позволяет агентам выполнять действия в реальном мире.
-
Готовые компоненты: Koog поставляется с набором предварительно созданных решений для общих задач AI-инженерии, что значительно ускоряет разработку.
-
Интеллектуальное сжатие истории.
-
Постоянная память агента: агенты могут сохранять знания между сессиями и даже между разными агентами, что позволяет создавать более сложные и адаптивные системы.
-
Создание графовых стратегий из инструментов для решения задач.
-
Модульная система функций.
-
Масштабируемая архитектура: Koog способен обрабатывать рабочие нагрузки от простых чат-ботов до корпоративных приложений.
Также Koog поддерживает работу с различными провайдерами LLM, как внешними (ChatGPT, Gemini, Claude и т.п.), так и локальными (Ollama).
Начинаем свою работу с подключения зависимости в gradle. Вы можете это делать как в основном модуле, так и выделенном под код вашего агента:
dependencies { implementation("ai.koog:koog-agents:0.7.3") }
Для определения минимального агента достаточно указать AIAgent, задать ему нужные PromptExecutor (исполнитель промтов, завязанный на провайдер) и LLModel (поддерживаемая модель для исполнителя).
val agent = AIAgent( promptExecutor = executor, llmModel = model, systemPrompt = "You are a helpful assistant.")val result = agent.run("Explain Kotlin Multiplatform.")println(result)// Для ChatGPT val agent = AIAgent( executor = simpleOpenAIExecutor(apiKey), systemPrompt = "You are a helpful assistant.", llmModel = OpenAIModels.Chat.GPT4o )// Для Ollama val agent = AIAgent( executor = simpleOllamaAIExecutor(ollamaHost), systemPrompt = "You are a helpful assistant.", llmModel = LLModel( provider = OllamaLLMProvider(), id = "qwen3-coder:30b") )
Также в агенте можно задать:
-
набор инструментов,
-
стратегию (граф с порядком вызова комбинации инструментов),
-
некоторые ограничения.
Koog применяют в задачах, где поведение системы нельзя свести к одному вызову модели. Если модель должна не только сгенерировать ответ, но и вызывать нужные инструменты, соблюдать порядок шагов, переиспользовать определенную логику и удерживать шаблоны, заготовки для проекта, требуется агентный контур, а не только системный промт. Именно к таким задачам и относится, например, генерация кода приложения по слоям.
В целом, агенты на Koog в зависимости от предстоящей задачи можно разделить на следующие группы:
-
Prompt-only агент Минимальный сценарий: один запрос к модели, без tools. Хорошо для быстрых задач, плохо для автоматизации.
-
Tool-driven агент Модель умеет вызывать функции/инструменты. Хорошо для интеграций и контролируемых действий.
-
Graph-based агент (workflow) Сценарий разложен на узлы и переходы. Хорошо для многошаговых процессов
-
Composed / Multi-agent стиль Несколько подзадач или субграфов, каждый со своей ролью/набором tools. Применяется, когда одна “универсальная” модель становится слишком сложной.
Наша задача — создать инструмент, который будет помогать строить приложение на KMP. Поэтому выбираем агент на графе с использованием инструментов.
Инструменты
Будем исходить из того, что нам нужно в нашем приложении:
-
сетевой слой (на Ktor),
-
слой презентации и архитектуры,
-
бизнес-логика.
Мы хотим, чтобы у нас создавались как отдельные части приложения, так и все вместе. Поэтому нам потребуются наборы инструментов:
-
для генерации сети и бизнес-логики (NewsAppTooling)
-
для генерации архитектуры (LayerTemplateTools)
-
для цельного рабочего процесса — оркестрации (OrchestratorTools).
В Koog инструмент — это Kotlin-метод, помеченный @Tool. Он входит в класс ToolSet, а затем регистрируется в реестре инструментов, ToolRegistry.
Рассмотрим на примере генератора слоев:
class LayerTemplateTools : ToolSet { @Tool @LLMDescription("Template for Ktor client, serialization, and NetworkConfig wiring in shared/commonMain.") fun generateNetworkLayerTemplate(): String = NewsAppTooling.networkLayerTemplate()}
На уровне API здесь происходят две вещи:
-
@Toolделает метод доступным модели; -
@LLMDescriptionобъясняет, зачем и когда его вызывать. Это правило и ключевые слова.
Таким образом, шаг перестает быть неявным рассуждением модели и становится наблюдаемым действием.
Не для каждого шага нужно создание своего инструмента, что-то можно оставить обычной функцией:
-
если модель должна явно решить, нужен этот шаг или нет, это инструмент;
-
если шаг нужен только внутренней реализации, это обычная функция.
инструмент = агентное действиеобычная функция = реализация агентного действия
Готовый инструмент регистрируем в ToolRegistry, реестре инструментов:
fun createToolRegistry(session: SessionSymbolStore): ToolRegistry = ToolRegistry { tools(OrchestratorTools(session).asTools()) tools(LayerTemplateTools().asTools())}
Реестр инструментов подключается к AI агенту во время его создания:
AIAgent( promptExecutor = executor, llmModel = LLModel( provider = OllamaLLMProvider(), id = "qwen3-coder:30b", capabilities = listOf(LLMCapability.Tools), ), systemPrompt = systemPrompt, temperature = 0.2, toolRegistry = createToolRegistry(session))
Теперь рассмотрим подробнее инструменты, которые мы будем использовать.
Инструменты оркестрации: план и общий язык
Первая группа инструментов — OrchestratorTools — код не пишет. Она управляет ходом всей сессии. Её задача: превратить свободный запрос пользователя в конкретный, воспроизводимый план, а затем закрепить ключевые названия, чтобы агент и пользователь дальше говорили на одном языке.
Два главных метода здесь:
@Toolfun planKmpNewsApp(brief: String): String@Toolfun rememberGenerationSymbol(symbol: String, role: String): String
Первый, planKmpNewsApp, берёт пожелания вроде «сделай новостной клиент» и выдаёт на выходе не общие слова, а чёткую спецификацию: архитектурные слои, порядок их реализации, ограничения (например, по NetworkConfig), список компонентов и даже последовательность следующих вызовов. Получается не импровизация, а понятная инструкция: имея такой план, агент не потеряется даже в длинной цепочке шагов.
Второй, rememberGenerationSymbol, закрывает вечную проблему кодогенерации — несогласованные имена. Если в одном ответе репозиторий уже назвали NewsRepositoryImpl, а use case — GetTopHeadlinesUseCase, этот инструмент запоминает выбор. При следующем шаге не придётся заново придумывать имена и рисковать, что репозиторий вдруг окажется NewsDataRepository. Вся команда (агент и пользователь) продолжает работать в единой системе обозначений.
Оркестратор вынесен отдельно, потому что планирование и фиксация названий не относятся ни к сети, ни к UI, ни к бизнес-логике. Если смешать их с генерацией кода, получится инструмент с размытыми обязанностями: он будет и управлять процессом, и создавать шаблоны одновременно. Разделение даёт простую границу: OrchestratorTools решает «что и в каком порядке делаем», а следующие инструменты — «как именно это будет выглядеть в коде».
Шаблоны слоёв: сеть, логика, интерфейс
Если оркестратор прокладывает маршрут, то LayerTemplateTools — это фабрика заготовок для каждого архитектурного слоя. Внутри три метода:
@Toolfun generateNetworkLayerTemplate(): String@Toolfun generateDomainLayerTemplate(): String@Toolfun generatePresentationLayerTemplate(): String
Каждый возвращает не готовое приложение, а типовой каркас:
-
сетевой — настройка Ktor-клиента, сериализации, конфигурация в
shared/commonMain; -
доменный — интерфейсы репозиториев, реализации, use‑cases (всё в
shared); -
презентационный — ViewModel и Compose‑экран (в
composeAppилиshared).
Мы разделяем генерацию слоев, чтобы, во-первых, не смешивать их внутреннюю логику и инструменты, во-вторых, перезапускать по отдельности, в-третьих, для читабельности. Нет одного огромного метода generateApp(), который валится целиком. Если что-то пошло не так на уровне доменного слоя, это не ломает сетевой или UI. Можно гибко перезапускать или корректировать отдельные шаги.
В итоге OrchestratorTools и LayerTemplateTools работают в паре по принципу «сначала договариваемся, потом делаем». Первый держит фокус на общей картине и терминах, второй — дает готовые архитектурные лекала. Граница между ними и позволяет агенту одновременно быть стратегом и аккуратным исполнителем.
Шаблоны
Чтобы генерация компонента или слоя отвечала нашим ожиданиям, задаем шаблоны и заготовки для примера. В текущем проекте эту роль играет NewsAppTooling.
object NewsAppTooling { fun planKmpNewsApp(brief: String, symbols: Map<String, String>): String = """ |# KMP News (newsapi.org) — implementation plan | |## Architecture (MVVM + use case, coroutines, Compose, Ktor in common) |1. **NetworkConfig** — client sets `NetworkConfig.install(apiKey)` before any request. |2. **Data** — Ktor `HttpClient`, JSON DTOs (`kotlinx.serialization`), `NewsRepositoryImpl` calling `v2/top-headlines`. |3. **Domain** — `NewsRepository` interface, `GetTopHeadlinesUseCase`. |4. **Presentation** — `NewsViewModel`, Compose list screen. """.trimMargin()}
Этот слой решает одну архитектурную проблему: модель не должна каждый раз заново “изобретать” базовую архитектуру проекта.
Рассмотрим некоторые шаблоны:
fun networkLayerTemplate(): String = """ |// shared/src/commonMain/.../news/NetworkConfig.kt |object NetworkConfig { | private var apiKey: String? = null | fun install(apiKey: String) { this.apiKey = apiKey } | fun requireApiKey(): String = apiKey ?: error("Call NetworkConfig.install(apiKey) from Android/iOS entry before network calls") |} | |// Ktor client in commonMain + expect/actual or multiplatform engines |// NewsApi: suspend fun topHeadlines(country: String = "us"): HttpResponse or typed DTO """.trimMargin()
fun domainLayerTemplate(): String = """ |interface NewsRepository { | suspend fun topHeadlines(country: String = "us"): Result<List<Article>> |} | |class GetTopHeadlinesUseCase(private val repository: NewsRepository) { | suspend operator fun invoke(country: String = "us") = repository.topHeadlines(country) |} """.trimMargin()
fun presentationLayerTemplate(): String = """ |class NewsViewModel(private val useCase: GetTopHeadlinesUseCase) : ViewModel() { | private val _state = MutableStateFlow(NewsUiState(loading = false, articles = emptyList(), error = null)) | val state: StateFlow<NewsUiState> = _state.asStateFlow() | fun load(country: String = "us") { viewModelScope.launch { /* invoke useCase, fold Result */ } } |} | |@Composable |fun NewsScreen(viewModel: NewsViewModel = viewModel { ... }) { /* LazyColumn, error, loading */ } """.trimMargin()
Графы и стратегии
Мы не хотим вручную вызывать все инструменты. Нам нужно ввести промт и получить результат. Как его достичь, наш агент должен понимать сам. Для того, чтобы получить именно пошаговую реализацию, или план и реализацию, нам потребуется создать стратегию на основе графа инструментов.
Минимальная схема мыслится так:
[Start] | [CollectContextNode] —(context)–> [PlanningSubgraphWithTools] —(result)–> [Finish] Что здесь важно:
-
узел делает один смысловой шаг;
-
переходы явно передают преобразованный выход;
-
subgraph позволяет “вложить” этап, где модель активно вызывает tools. :
val strategy = strategy<Input, Output> { val collect by nodeLLMRequest(“collect”, allowToolCalls = false) val plan by subgraphWithTask<Input, Output>(tools = toolRegistry.tools) { input -> “Сделай план и используй tools” }
edge(nodeStart forwardTo collect transformed { input -> "Собери контекст: $input" })edge(collect forwardTo plan transformed { msg -> msg.content })edge(plan forwardTo nodeFinish)
}
В нашем проекте граф выглядит немного по-другому, т.к. у нас есть повторные фазы:
fun kmpNewsDevelopmentStrategy() = strategy<String, String>("kmp-news-dev") { val nodeCallLLM by nodeLLMRequest("sendInput") val nodeExecuteTool by nodeExecuteTool("nodeExecuteTool") val nodeSendToolResult by nodeLLMSendToolResult("nodeSendToolResult") edge(nodeStart forwardTo nodeCallLLM) edge(nodeCallLLM forwardTo nodeExecuteTool onToolCall { true }) edge(nodeCallLLM forwardTo nodeFinish onAssistantMessage { true }) edge(nodeExecuteTool forwardTo nodeSendToolResult) edge(nodeSendToolResult forwardTo nodeFinish onAssistantMessage { true }) edge(nodeSendToolResult forwardTo nodeExecuteTool onToolCall { true })}
Что здесь означает каждый узел
-
nodeLLMRequest— отправляет запрос в модель; -
nodeExecuteTool— исполняет вызванный инструмент; -
nodeLLMSendToolResult— возвращает результат инструмента обратно в модель; -
nodeFinish— завершает работу агента.
Почему переходы соединены именно так
-
nodeStart -> nodeLLMRequest: выполнение всегда начинается с модели; -
nodeLLMRequest -> nodeExecuteToolпоonToolCall: если модель запросила инструмент, управление передается исполнителю; -
nodeLLMRequest -> nodeFinishпоonAssistantMessage: если пришел обычный ответ, работа завершается; -
nodeExecuteTool -> nodeLLMSendToolResult: после исполнения инструмента его результат обязан вернуться в модель; -
nodeLLMSendToolResult -> nodeExecuteToolпоonToolCall: после получения результата модель может запросить следующий инструмент; -
nodeLLMSendToolResult -> nodeFinishпоonAssistantMessage: если после результата инструмента модель завершила рассуждение, агент заканчивает работу.
Как выглядит выполнение задачи в текущем агенте
Полный сценарий выполнения текущего агента на одной задаче выглядит так.
Пользователь формулирует запрос:
Добавь feature top-headlines для KMP News app
Агент проходит его так:
-
модель получает запрос;
-
оркестратор строит план через
planKmpNewsApp; -
при необходимости фиксируется naming через
rememberGenerationSymbol; -
последовательно вызываются шаблоны сетевого, доменного и презентационного слоев;
-
после этого агент либо завершает ответ, либо продолжает следующий ход уже с опорой на детерминированные результаты предыдущих вызовов.
Короткая схема:
user request -> planKmpNewsApp -> rememberGenerationSymbol (optional) -> generateNetworkLayerTemplate -> generateDomainLayerTemplate -> generatePresentationLayerTemplate -> final answer
Подробный смысл каждого вызова в этой последовательности:
-
planKmpNewsAppВозвращает определенный план реализации. Это первая обязательная точка, потому что без нее агент не фиксирует, какие слои должны появиться, какие компоненты обязательны и в каком порядке двигаться дальше. -
rememberGenerationSymbolВызывается не всегда. Он нужен только тогда, когда в ходе сессии появилось имя, которое нужно закрепить и переиспользовать дальше без дрейфа. -
generateNetworkLayerTemplateВозвращает каркас сетевого слоя:NetworkConfig, схему подключенияKtor, базовые ожидания к API и общую форму кода вcommonMain. -
generateDomainLayerTemplateВозвращает каркас доменного слоя: интерфейс репозитория и use case. Этот шаг отделяет транспорт и сериализацию от доменной модели и сценариев использования. -
generatePresentationLayerTemplateВозвращает каркас презентационного слоя:ViewModel, состояние экрана и общую формуCompose-экрана.
Именно поэтому оркестратор должен быть описан отдельно от layer tools. В этой последовательности он не «еще один инструмент», а управляющий уровень, который сначала определяет план и согласованность имен, а уже затем передает работу инструментам прикладных слоев.
Прикрепляем к Continue
Что ж, нам надо, чтобы инструментом можно было свободно пользоваться. Если мы будем запускать модуль агента, как есть, то пользоваться им можно будет только через cli. Превратим нашего агента в MCP-сервер. MCP (Model Context Protocol) — это протокол, через который внешний клиент вызывает инструменты и получает их результаты в стандартной форме. В этой архитектуре он нужен не для внутренней работы Koog-агента, а для публикации возможностей системы наружу. Установим библиотеку: https://github.com/modelcontextprotocol/kotlin-sdk
commonMain { dependencies { // Works as a common dependency as well as the platform one implementation("io.modelcontextprotocol:kotlin-sdk:$mcpVersion") }}
Теперь займемся самим сервером:
val server = Server( serverInfo = Implementation(name = "koog-kmp-planner", version = "1.0.0"), options = ServerOptions( capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = false)), ), )
Подключим нашего агента к модулю и добавим инструменты серверу:
// Пример подключения инструментаserver.addTool( name = "generate_presentation_layer_template", description = "ViewModel + Compose screen template.", inputSchema = ToolSchema(properties = buildJsonObject { }), toolAnnotations = ToolAnnotations(readOnlyHint = true, openWorldHint = true), ) { CallToolResult(content = listOf(TextContent(NewsAppTooling.presentationLayerTemplate()))) }
Настроим наш сервер на запуск:
val transport = StdioServerTransport( inputStream = System.`in`.asInput(), outputStream = System.out.asSink().buffered(), ) runBlocking { val session = server.createSession(transport) val done = Job() session.onClose { done.complete() } done.join() }
Упакуем в fat JAR:
tasks.build { dependsOn(tasks.shadowJar)}
Теперь нам нужно создать скрипт для подключения MCP-сервера к Continue:
name: Local Agentversion: 1.0.0schema: v1models: - name: Qwen3-Coder 30b provider: ollama model: qwen3-coder:30b roles: [chat, edit, apply]mcpServers: - name: koog-kmp-planner type: stdio command: <JAVA_HOME> args: - -jar - <путь к jar> - --stdio env: OLLAMA_BASE_URL: http://localhost:11434 OLLAMA_MODEL: qwen3-coder:30b TARGET_PROJECT_ROOT: <корень проекта для поиска настроек>
Проверим, что инструменты нашлись:

Пример работы нашего генератора:

Теперь поговорим о проблемах. Результат зависит от той LLM, которую вы будете использовать. Будьте готовы к такому:

Или такому:

Генерация агента
Как я анонсировала, агент был сгенерирован с помощью Cursor и такого промта:
. Раздели на отдельные инструменты, соедини их в стратегии. Мне нужны: - инструмент оркестратор для планировки- агент для разработки- отдельные инструменты для разработки слоев приложения.В генерируемом приложении должен быть сетевой слой на Retrofit, архитектура MVVM + usecase, корутины, UI на Compose. @Android SDK Агент должен быть упакован в jar. Mcp сервер подготовь отдельным таргетом. Создай сэмпл таргет для проверки работы агента.Задавай вопросы прежде, чем сделать
На первый взгляд, все указано для создания. Но на деле, пришлось просить добавить MCP, создать конфигурацию под Continue и многое другое. Все зависит от вашей модели.
Вы можете отдельно настроить rules для вашего плагина, добавить команды и промты. Если сравнивать производительность и корректность работы плагина с собственным MCP и готового агента с настроенными навыками, то все индивидуально. Зависит как от ваших настроек и кода, так и модели, которую вы выбрали. Иногда действительно достаточно взять просто более мощную LLM, чтобы ваши труды оправдались.
Код решения ловите здесь До новых встреч)
ссылка на оригинал статьи https://habr.com/ru/articles/1027130/