- BrainTools - https://www.braintools.ru -
Интернет завален реализациями на Питоне, но иногда удобнее разбираться с технологиями на своём основном языке. Для меня это Kotlin.
Если вы программист, наверняка к вам приходят знакомые и предлагают писать агентов. Реализовав оного самостоятельно, вы поймете, что задача из себя представляет.
Статья обещает соблюдать два принципа, упрощающих восприятие [1]:
Движение от частного к общему, потому что легче воспринимать примеры, чем абстракцию.
Быстрая обратная связь, как с REPL [2].
Агента реализуем так, чтобы легко было заменить лежащую в основе LLM. Посмотрим, как отличается работа при использовании REST API в сравнении с SDK, пощупаем Гигачат и Anthropic.
Ах да, 🪐 KOSMOS — акроним.
Что такое агент [3]
– Как работают агенты: пример в чате [4]
– Как работают агенты: пример с API [5]
Реализация функций агента [6]
– Контракт функций [7]
– Пишем первую функцию — ListFiles [8]
– Пишем функцию чтения файла [9]
– Остальные функции работы с файловой системой [10]
– Заботимся о безопасности [11]
Реализация агента с Гигачатом [12]
– Чат с агентом-попугаем [13]
– Гигачат по REST API. Запрос токена [14]
– Гигачат по REST API. Общение с LLM [15]
– Подключаем функций [16]
– Реализация агента [17]
Реализация агента через Anthropic SDK [18]
– Подготовка [19]
– Адаптер над функциями и агент [20]
Что дальше? [21]
Если попросить LLM умножить 2 больших числа, она ошибется. Решение — дать ей калькулятор. LLM с калькулятором — это уже агент.
В общем случае агент — это компьютерная программа, использующая возможности LLM для решения задач с помощью других программ.
Продвинутые агенты могут иметь долгосрочную память [22] (векторная база данных, RAG [23]), хитрые промпты для рефлексии и самокритики.
┌──────────────────────┐
│ Short-term mem │
├──────────────────────┤
│ Long-term mem │
└───────────▲──────────┘
│
┌───────┴───────┐
│ Memory │
└───────▲───────┘
│
┌───────────────────┐ │
│ Calendar() │ │
├───────────────────┤ │
│ Calculator() │ │
├───────────────────┤ │
│ CodeInterpreter() │ ┌───────────────────────────────────┐
├───────────────────◀────│ Agent │
│ Search() │ └─────────────────────┬─────────────┘
├───────────────────┤ │
│ ...more │ │
└───────────────────┘ │
│
┌─────▼─────┐
│ Planning │
└─────┬─────┘
│
┌────────▼───────────────┐
│ Reflection │ Self-crit │
│ Chain-of-thoughts │
│ Subgoal-decomposition │
└────────────────────────┘
Откройте любой доступный вам LLM-чат и напишите:
Если я попрошу сложить два числа, ты можешь вызывать калькyлятор.
Для этого напиши:
```json
{
"n1": number1,
"n2": number2,
"operation": "+"
}
```
И следующим сообщением получишь ответ.
А теперь сложи 22 и 33
Json объект и его описание — это tool (в терминах Anthropic, OpenAI, Deepseek) или функция [24] (в терминах Гигачат). В статье мы будем называть «тулы» функциями. Текстом выше мы дали понять LLM, что у нее есть функция «калькулятор».
Я пробовал с Deepseek, Qwen, ChatGpt, Гигачат — все ответили:
{
"n1": 22,
"n2": 33,
"operation": "+"
}
Такое сообщение легко парсится. Все что нам теперь нужно — выполнить операцию на калькуляторе и написать «55» в чат. LLM ответит что-то вроде:
Сумма чисел 22 и 33 равна 55. 😊
Давайте попробуем притвориться агентом: сами будем вызывать LLM.
Для начала понадобится завести аккаунт Гигачата [25], получить ключ и записать его в переменные окружения:
export GIGA_KEY=ключ
Запросим токен, которого хватит на 30 минут:
curl -L -X POST 'https://ngw.devices.sberbank.ru:9443/api/v2/oauth'
-H 'Content-Type: application/x-www-form-urlencoded'
-H 'Accept: application/json'
-H 'RqUID: 9aa1df35-33f6-43fc-b92e-1e61384c8660'
-H "Authorization: Basic $GIGA_KEY"
--data-urlencode 'scope=GIGACHAT_API_PERS'
Если запрос не выполняется с ошибками сертификата, попробуйте передать флаг -k или пропишите сертификаты Сбера по инструкции [26].
В ответ придет токен, который тоже для удобства положим в переменные окружения:
export GIGA_TOKEN=token
Теперь можно отправить первые сообщения (детали в документации [27]).
Зададим первый вопрос Гигачату о том, какие файлы лежат в кодовой базе проекта.
curl -L 'https://gigachat.devices.sberbank.ru/api/v1/chat/completions'
-H 'Content-Type: application/json'
-H 'Accept: application/json'
-H "Authorization: Bearer $GIGA_TOKEN"
-d '{
"model": "GigaChat-Max",
"messages": [
{
"role": "system",
"content": "Ты — ассистент, помогающий писать код"
},
{
"role": "user",
"content": "Что лежит в директории текущего проекта?"
}
],
"function_call": "auto",
"functions": [
{
"name": "ListFiles",
"description": "Запускаем ls команду по текущему пути. Точка (.) означает текущую папку",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Путь к директории, файлы которой покажем"
}
}
}
}
]
}'
Мы указали, что у LLM есть функция «ListFiles», требующая path в качестве параметра, и спросили, что лежит в директории проекта. В ответ Гигачат запрашивает function_call.
{
"choices": [
{
"message": {
"content": "",
"role": "assistant",
"function_call": {
"name": "ListFiles",
"arguments": {
"path": "."
}
},
"functions_state_id": "e379e132-2cf8-4ce1-8545-c9c94cbebb1b"
},
"index": 0,
"finish_reason": "function_call"
}
],
"created": 1752855939,
"model": "GigaChat-Max:2.0.28.2",
"object": "chat.completion",
"usage": {
"prompt_tokens": 88,
"completion_tokens": 23,
"total_tokens": 111,
"precached_prompt_tokens": 3
}
}
Добавляем запрос и результат вызова функции в messages. Ожидаем получить ответ, основанный на этом вызове. Не забудьте проставить вернувшийся functions_state_id:
curl -L 'https://gigachat.devices.sberbank.ru/api/v1/chat/completions'
-H 'Content-Type: application/json'
-H 'Accept: application/json'
-H "Authorization: Bearer $GIGA_TOKEN"
-d '{
"model": "GigaChat-Max",
"messages": [
{
"role": "system",
"content": "Ты — ассистент, помогающий писать код"
},
{
"role": "user",
"content": "Что лежит в директории текущего проекта?"
},
{
"role":"assistant",
"content":
"{"name": "ListFiles", "arguments": {"path": "."}} ",
"functions_state_id": "e379e132-2cf8-4ce1-8545-c9c94cbebb1b"
},
{
"role": "function",
"content": "["README.md", "src/", "src/main.kt/"]",
"name": "ListFiles"
}
],
"function_call": "auto",
"functions": [
{
"name": "ListFiles",
"description": "Запускаем ls команду по текущему пути. Точка (.) означает текущую папку",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Путь к директории, файлы которой покажем"
}
}
}
}
]
}'
Ответ пришел, как мы и ожидали: Гигачат, основываясь на вызове функции, ответил на вопрос про файлы в директории.
{
"choices": [
{
"message": {
"content": "В текущей директории проекта находятся следующие элементы:n- README.mdn- src/n- src/main.kt",
"role": "assistant",
"functions_state_id": "de68b8a0-c2c7-448b-af8b-8a1b652fccd5"
},
"index": 0,
"finish_reason": "stop"
}
],
"created": 1752856961,
"model": "GigaChat-Max:2.0.28.2",
"object": "chat.completion",
"usage": {
"prompt_tokens": 150,
"completion_tokens": 26,
"total_tokens": 176,
"precached_prompt_tokens": 3
}
}
Давайте начнем с простого — реализуем функции (тулы), которые помогут агентy взаимодействовать с миром.
Понадобится окружение, где мы сможем запустить Kotlin код. Можно создать новый Kotlin-проект в Intellij IDEA или взять скелет из моего репозитория KOSMOS-agent [28].
tree -I '.*|.git' --prune
.
├── gradle...
├── gradle.properties
├── gradlew
├── settings.gradle.kts
├── build.gradle.kts
└── src
├── main
│ └── kotlin
│ ├── Main.kt
│ └── tool
│ ├── files
│ │ └── ToolListFiles.kt
│ └── ToolSetup.kt
└── test
├── kotlin
│ └── tool
│ └── ToolTest.kt
└── resources
├── directory
│ └── file.txt
└── test.txt
Из зависимостей не забудьте добавить корутины в build.gradle:
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.Coroutines}")
testImplementation(kotlin("test"))
}
Передавая функцию Гигачату, мы думали о том, как объяснить ее чату и какой алиас ей дать. Так что нам понадобятся имя, описание и сама функция:
interface ToolSetup<input> {
val name: String
val description: String
operator fun invoke(input: Input): String
}
Кроме того, агенту нужно будет рассказать и о параметрах функции. У ListFiles есть path. Отложим решение о том, как предоставить эту информацию, на потом.
Для полноценного функционирования агента-помощника в написании кода нам понадобятся следующие функции:
Чтение файла (echo)
Перечисление файлов (ls)
Изменение файла (sed -e)
Создание файла (>>)
Удаление файла (rm)
Поиск текста в файле (find)
Просто дать терминал мы не хотим, потому что сложнее будет обеспечить безопасность. Сейчас мы можем описывать параметры, а с терминалом придется парсить команду.
Реализуем ListFiles, как в примере использования API Гигачата:
object ToolListFiles : ToolSetup {
override val name = "ListFiles"
override val description = "Runs bash ls command at a given path. Dot (.) means current directory"
override fun invoke(input: Input): String {
TODO()
}
data class Input(val path: String = ".")
}
Обратите внимание [29], мы дали описание на английском. Считается, что LLM лучше работают с английскими промптами. Почему так? На английском больше данных для обучения [30], и по количеству токенов английский экономнее (нет падежей ).
Начнем с написания теста.
@Test
fun `test ToolListFiles`() {
val input = ToolListFiles.Input("src/test/resources")
val resources = ToolListFiles()
assertEquals("[directory/,directory/file.txt,test.txt]", resources)
println(resources)
}
И в src/test/resources положим папку directory и два файла: test.txt и directory/file.txt. Попробуйте запустить тест и убедиться, что пока что tool не работает.
./gradlew test
Результат:
> Task :test FAILED
ToolTest > test ToolListFiles() FAILED
kotlin.NotImplementedError at ToolTest.kt:11
Набросаем наивную реализацию:
override fun invoke(input: Input): String {
val base = File(input.path)
val files = base.list()
return files.joinToString(",", prefix = "[", postfix = "]")
}
Запускаем тест:
Expected :[directory/,directory/file.txt,test.txt]
Actual :[directory,test.txt]
Осталось поддержать поиск вложенных файлов. Kotlin предоставляем отличную функцию File.walkTopDown (DFS [31] по файлам), возвращающую sequence. То есть можно пользоваться преобразованием коллекций [32] без накладных расходов в виде создания по новой коллекции на каждом операторе:
override fun invoke(input: Input): String {
val base = File(input.path)
val files = base.walkTopDown() // sequence
.filter { it != base }
.map { file ->
val relPath = file.relativeTo(base).path
if (file.isDirectory) "$relPath/" else relPath
}
return files.joinToString(",", prefix = "[", postfix = "]")
}
Тест должен быть пройден. Функцию можно улучшить, добавив еще параметры с исключениями. Например, мы не хотим тратить токены на отправку данных из папок .git или .idea. Реализацию фичи оставлю на совести читателя.
Опять начнем с теста. Допишем в src/test/resources/test.txt «Test content» с новой строкой.
@Test
fun `test ToolReadFile`() {
println(File("src/test/resources/test.txt").readText())
val result = ToolReadFile(ToolReadFile.Input("src/test/resources/test.txt"))
assertEquals("Test contentn", result) // n для новой строки
}
object ToolReadFile : ToolSetup {
override val name = "ReadFile"
override val description = "Retrieve the contents of a specified file using a relative path. " +
"Use this to read a file's contents. Avoid using it with directory paths"
override fun invoke(input: Input): String {
val path = input.path
val file = File(path)
return file.readText()
}
data class Input(val path: String)
}
Следующий тест создает файл, меняет его, ищет текст в файлах и удаляет файл:
@Test
fun `test ToolNewFile, ToolModifyFile, ToolFindTextInFiles, ToolDeleteFile lifecycle`() {
val content = "Test"
val resources = "src/test/resources"
val newFileName = "${UUID.randomUUID()}.txt"
val path = "$resources/$newFileName"
// create new file
ToolNewFile(ToolNewFile.Input(path, text = content))
val fileContent = ToolReadFile(ToolReadFile.Input(path))
assertEquals(content, fileContent)
// modify new
val newContent = "New"
ToolModifyFile(ToolModifyFile.Input(path, oldText = content, newText = newContent))
// find
val findResult = ToolFindTextInFiles(ToolFindTextInFiles.Input(path = resources, newContent))
assertEquals("[$newFileName]", findResult)
// delete
ToolDeleteFile(ToolDeleteFile.Input(path))
assertThrows { ToolReadFile(ToolReadFile.Input(path)) }
}
object ToolNewFile : ToolSetup {
override val name = "NewFile"
override val description = "Creates a new file at the given path with the provided content."
override fun invoke(input: Input): String {
val file = File(input.path)
file.parentFile?.mkdirs()
file.writeText(input.text)
return "File created at ${input.path}"
}
data class Input(
val path: String,
val text: String
)
}
object ToolModifyFile : ToolSetup {
override val name = "EditFile"
override val description = "Replace text in a file. Replaces 'old_text' with 'new_text' in the specified file. "
override fun invoke(input: Input): String {
val file = File(input.path)
val content = file.readText()
val newContent = content.replace(input.oldText, input.newText)
file.writeText(newContent)
return "OK"
}
data class Input(
val path: String,
val oldText: String,
val newText: String,
)
}
object ToolFindTextInFiles : ToolSetup {
override val name = "FindTextInFiles"
override val description = "Search for a specific text across all files in a directory (recursively) " +
"and return matching file paths."
override fun invoke(input: Input): String {
val baseDir = File(input.path)
val matchedFiles = baseDir.walkTopDown()
.filter { it.isFile && it.readText().contains(input.text) }
.map { it.relativeTo(baseDir).path }
.toList()
return matchedFiles.joinToString(",", prefix = "[", postfix = "]")
}
data class Input(
val path: String = ".",
val text: String,
)
}
object ToolDeleteFile : ToolSetup {
override val name = "DeleteFile"
override val description = "Deletes a file at the given path."
override fun invoke(input: Input): String {
val file = File(input.path)
file.delete()
return "File deleted at ${input.path}"
}
data class Input(val path: String)
}
Тест должны проходить. Если возникнут проблемы, можете посмотреть на проект KOSMOS-agent [33] и взять код оттуда.
Подустали? Давайте просыпаться. Ниже написан тест, который не стоит(!) запускать, пока вы не будете на 100% уверены в реализации:
class ToolSecurityTest {
@Test
fun `test delete file rejects paths outside project root`() {
assertThrows {
ToolDeleteFile.invoke(ToolDeleteFile.Input("/"))
}
}
}
Рисковать или нет — дело читателя. Автор статьи все еще пишет, а значит, тест был пройден. Вот моя реализация:
object ToolDeleteFile : ToolSetup {
// ...
override fun invoke(input: Input): String {
val file = File(input.path)
FilesToolUtil.requirePathIsSave(file)
file.delete()
return "File deleted at ${input.path}"
}
}
object FilesToolUtil {
private val projectRoot = File(".").canonicalFile
fun isPathSafe(file: File): Boolean {
val canonicalPath = file.canonicalFile
return canonicalPath.startsWith(projectRoot)
}
@Throws(BadInputException::class)
fun requirePathIsSave(file: File) {
if (!isPathSafe(file)) {
throw BadInputException("Access denied: File path must be within project directory")
}
}
}
Чат с агентом — это просто:
while (true) {
print(">")
val input = kotlin.io.readLine() ?: break
if (input.lowercase() == "exit") break
println(input)
// тут будет отправка сообщение к Гигачату и обработка ответа
}
Не хочется сразу завязываться на конкретную реализацию LLM, поэтому предлагаю вынести общение в абстракцию. Так будет выглядеть Flow сообщений пользователя:
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
suspend fun main() {
val agent = ParrotAgent(userInputFlow())
agent.run().collect { text -> print(text) }
}
/** Агент, повторяющий сообщение */
class ParrotAgent(private val userMessages: Flow) {
fun run(): Flow = userMessages
}
private fun userInputFlow(): Flow = flow {
println("Type `exit` to quit")
while (true) {
print("> ")
val input = readLine() ?: break
if (input.lowercase() == "exit") break
emit(input)
println("n")
}
}
Попробуйте запустить и пообщаться с первым агентом.
Проверьте, что ключ Гигачата доступен из переменных окружения:
fun main() {
val gigaKey = System.getenv("GIGA_KEY")
println(gigaKey)
}
Если вы его проставили, а печатаеся null, переоткройте Intellij IDEA.
Напишем код на запрос токена с популярной библиотекой Ktor [34]. Понадобится прописать зависимости в build.gradle:
dependencies {
// ... остальные зависимости
// ktor
implementation("io.ktor:ktor-client-core:${Versions.Ktor}")
implementation("io.ktor:ktor-client-cio:${Versions.Ktor}")
implementation("io.ktor:ktor-client-content-negotiation:${Versions.Ktor}")
implementation("io.ktor:ktor-client-auth:${Versions.Ktor}")
implementation("io.ktor:ktor-serialization-kotlinx-json:${Versions.Ktor}")
implementation("io.ktor:ktor-serialization-jackson:${Versions.Ktor}")
}
И сам код на запрос авторизации:
object GigaAuth {
suspend fun requestToken(apiKey: String): String {
val client = HttpClient(CIO) {
gigaDefaults()
}
val response = client.submitForm(
url = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth",
formParameters = Parameters.build {
append("scope", "GIGACHAT_API_PERS")
}
) {
header("Content-Type", "application/x-www-form-urlencoded")
header("Authorization", "Basic $apiKey")
}.body<GigaResponse.Token>()
client.close()
return response.accessToken
}
}
Настройки для клиента Ktor вынесли в функцию, которая нам еще пригодиться для клиента чата.
import com.fasterxml.jackson.databind.DeserializationFeature
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.jackson.*
import java.security.cert.X509Certificate
import java.util.*
import javax.net.ssl.X509TrustManager
fun HttpClientConfig<CIOEngineConfig>.gigaDefaults() {
this.defaultRequest {
header(HttpHeaders.ContentType, "application/json")
header(HttpHeaders.Accept, "application/json")
header("RqUID", UUID.randomUUID().toString())
}
install(HttpTimeout) {
requestTimeoutMillis = 20000
}
install(ContentNegotiation) {
jackson { this.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) }
}
engine {
https {
trustManager = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
}
}
}
}
Проверяем, что токен запрашивается:
suspend fun main() {
val gigaKey = System.getenv("GIGA_KEY")
val gigaToken = GigaAuth.requestToken(gigaKey)
println(gigaToken)
}
Опишем классы для работы с API Гигачата.
import com.fasterxml.jackson.annotation.JsonProperty
import java.util.*
object GigaResponse {
data class Token(
@JsonProperty("access_token") val accessToken: String,
@JsonProperty("expires_at") val expiresAt: Date
)
sealed interface Chat {
data class Ok(val choices: List<Choice>, val created: Long, val model: String) : Chat
data class Error(val status: Int, val message: String) : Chat
}
data class Choice(
val message: Message,
val index: Int,
@JsonProperty("finish_reason")
val finishReason: String
)
data class Message(
val content: String,
val role: GigaMessageRole,
@JsonProperty("function_call")
val functionCall: FunctionCall? = null,
@JsonProperty("functions_state_id")
val functionsStateId: String?
)
data class FunctionCall(
val name: String,
val arguments: Map<String, Any>
)
}
object GigaRequest {
data class Chat(
val model: String = "GigaChat-Max",
val messages: List<Message>,
@JsonProperty("function_call")
val functionCall: String = "auto",
val functions: List<Function>? = null,
)
data class Message(
val role: GigaMessageRole,
val content: String, // Could be String or FunctionCall object
@JsonProperty("functions_state_id")
val functionsStateId: String? = null
)
data class Function(
val name: String,
val description: String,
val parameters: Parameters
)
data class Parameters(
val type: String,
val properties: Map<String, Property>
)
data class Property(
val type: String,
val description: String? = null
)
}
@Suppress("EnumEntryName")
enum class GigaMessageRole { system, user, assistant, function }
Сам клиент для отправки запроса.
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.request.*
import io.ktor.http.*
class GigaChatAPI(private val auth: GigaAuth) {
private val client = HttpClient(CIO) {
var token = "" // get form env, or cache, or db
val gigaKey = System.getenv("GIGA_KEY")
gigaDefaults()
install(Auth) {
bearer {
loadTokens {
BearerTokens(token, "")
}
refreshTokens {
token = auth.requestToken(gigaKey)
BearerTokens(token, "")
}
}
}
}
suspend fun message(body: GigaRequest.Chat): GigaResponse.Chat {
val response = client.post("https://gigachat.devices.sberbank.ru/api/v1/chat/completions") {
setBody(body)
}
return when {
response.status.isSuccess() -> response.body<GigaResponse.Chat.Ok>()
else -> response.body<GigaResponse.Chat.Error>()
}
}
fun clear() = client.close()
}
Попробуем получить первый ответ от Гигачата, как мы делали руками через curl:
suspend fun main() {
val chat = GigaChatAPI(GigaAuth)
// временный код для демонстрации
val response = chat.message(
GigaRequest.Chat(
messages = listOf(
GigaRequest.Message(
role = GigaMessageRole.user,
content = "Help me find out what are the source files in the directory",
)
),
functions = listOf(
GigaRequest.Function(
name = "ListFiles",
description = "Show the files in the current directory path",
parameters = GigaRequest.Parameters(
"object",
properties = mapOf(
"path" to GigaRequest.Property(
type = "string",
description = "Relative path to list files from"
)
)
)
)
)
)
)
response.choices.forEach { (message, index, finishReason) ->
println(message)
}
}
После запуска должно напечататься что-то вроде:
Message(content=, role=assistant,
functionCall=FunctionCall(name=ListFiles, arguments={'path':.}),
functionsStateId=055e95ce-cbdf-46e7-ba22-6d3ad791f8c6)
Помните, мы отложили на потом решение о том, как передавать метаданные о параметрах запроса?
object ToolListFiles : ToolSetup {
/* остальной код */
data class Input(
// Как бы нам передать в Гигачат "Relative path to list files from"?
val path: String = "."
)
}
Решений, как добавить метаданные, много. Идиоматичный вариант — аннотации.
Annotations are a means of attaching metadata to code. — kotlinlang.org [35]
Можно использовать @JsonPropertyDescription из jackson, но для наглядности и независимости от сторонних библиотек предлагаю добавить свою:
@Target(AnnotationTarget.PROPERTY) // на property (val в data class)
@Retention(AnnotationRetention.RUNTIME) // достанем ее в Runtime
annotation class InputParamDescription(val value: String)
object ToolListFiles : ToolSetup {
/* остальной код */
data class Input(
@InputParamDescription("Relative path to list files from")
val path: String = "."
)
}
Домашнее задание (и читателю, и автору):
Перенести name и description в аннотации.
Реализовать проверку наличия аннотаций для функций. Задача со звездочкой — сделать это в compile time.
Остальные функции можете описать самостоятельно или скопировать с проекта KOSMOS-agent [36].
Теперь нам нужен способ перевести имеющиеся функции в удобоваримый для Гигачата вариант, что-то вроде:
interface GigaToolSetup {
val fn: GigaRequest.Function
operator fun invoke(
functionCall: GigaResponse.FunctionCall
): GigaRequest.Message
}
До реализации напишем тест на то, что мы хотели бы видеть:
class GigaToolTest {
private val gigaJsonMapper = jacksonObjectMapper()
@Test
fun `test function name and parameters setup`() {
val fn = ToolListFiles.toGiga().fn
assertEquals(fn.name, "ListFiles")
val jsonParams = gigaJsonMapper.writeValueAsString(fn.parameters)
assertEquals(
"""
{"type":"object","properties":{"path":{"type":"string","description":"Relative path to list files from"}}}
""".trimIndent(),
jsonParams
)
}
@Test
fun `test function invocation`() {
val toolsMap: Map = listOf(ToolListFiles.toGiga()).associateBy { it.fn.name }
val functionCall = GigaResponse.FunctionCall(
name = "ListFiles",
arguments = mapOf("path" to "src/test/resources"),
)
val result = toolsMap[functionCall.name]!!.invoke(functionCall)
assertEquals(
GigaRequest.Message(
role = GigaMessageRole.function,
content = """{"result":"[directory/,directory/file.txt,test.txt]"}""",
),
result
)
}
}
Прочесть аннотации можно через рефлексию, но если сделать это только один раз на старте приложения, то несколько миллисекунд ни на что не повлияют.
Добавим зависимость:
dependencies {
implementation(kotlin("reflect"))
}
val gigaJsonMapper = jacksonObjectMapper()
inline fun <reified Input> ToolSetup<Input>.toGiga(): GigaToolSetup {
val toolSetup = this
return object : GigaToolSetup {
override val fn: GigaRequest.Function = GigaRequest.Function(
name = toolSetup.name,
description = toolSetup.description,
parameters = GigaRequest.Parameters(
"object",
properties = HashMap<String, GigaRequest.Property>().apply {
val clazz = Input::class
for (kProperty: KCallable<*> in clazz.declaredMembers) {
val annotation = kProperty.findAnnotation<InputParamDescription>() ?: continue
val description = annotation.value
val type = kProperty.returnType.toString().substringAfterLast(".").lowercase()
val gigaProperty = GigaRequest.Property(type, description)
put(kProperty.name, gigaProperty)
}
}
)
)
override fun invoke(
functionCall: GigaResponse.FunctionCall,
): GigaRequest.Message {
return try {
val input: Input = gigaJsonMapper.convertValue(functionCall.arguments, Input::class.java)
val toolResult = toolSetup.invoke(input)
val gigaResult = gigaJsonMapper.writeValueAsString(
mapOf("result" to toolResult)
)
GigaRequest.Message(
role = GigaMessageRole.function,
content = gigaResult,
)
} catch (e: Exception) {
e.toGigaToolMessage()
}
}
}
}
fun Exception.toGigaToolMessage(): GigaRequest.Message {
return GigaRequest.Message(
role = GigaMessageRole.function,
content = """{"result": "${message ?: toString()}"}""",
)
}
Остаётся реализовать Агента. Так будет выглядеть первый упрощенный алгоритм:
┌─────────────────────────────── Loop 1-5 ───────────────────┐
│ User Agent LLM │
│ | | | │
│ |1. msg input | | │
│ └─────────────▶|2. add msg into msgs | │
│ | | | │
│ | | ┌──────────── Loop 3-5 ──────────────┐ │
│ | | │ | │
│ | | │3. send(msgs, tools) ──────────────▶| │
│ | | │ | │
│ | | │4-1. plain text ◀──────────────────| │
│ | | │ └─► print text | │
│ | | │ | │
│ | | │4-2. function call ◀────────────── | │
│ | | │ │ | │
│ | | │ │ exec tool | │
│ | | │ │ add result→msgs | │
│ | | │ │ | │
│ | | │◀───────────5. fn call? ───────────────▶│
│ | | └────────────────────────────────────┘ │
│ | | | │
│ |◀─────────────| | │
│ | 6. go to 1 | | │
└──┴──────────────┴──────────────────────────────────────┴───┘
Пользователь вводит сообщение.
Агент добавляет его в список сообщений.
Агент отправляет все сообщения + список функций в LLM.
LLM возвращает:
4.1. Обычный текст — печатаем текст.
4.2. Вызов функций — выполняем функции и добавляем результат в список сообщений.
Если был вызов функций, идем в шаг 3.
Возвращаемся к шагу 1.
Тизер — вот чего мне удалось добиться с простой реализацией, которую мы сейчас напишем:
Type `exit` to quit
> Whats inside the settings.gradle.kts file?
🪐:
If there were any subprojects or additional configurations, they would also appear here. However, based on the information you've shared, these two sections (`plugins` and `rootProject`) are the only parts present.
> Can you update this file and add a comment of what it does?
🪐:
😊🚀
> Can you remove this project?
🪐:
😉📌ei
🪐:
Here's an overview of both options:
1. **Remove Only File:** Deletes the `settings.gradle.kts` file while keeping other project components intact.
2. **Remove Entire Project:** Removes everything related to the project, including source code, resources, etc., assuming you're okay with losing data permanently.
> exit
Код агента c комментариями.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
class GigaAgent(
private val userMessages: Flow,
private val api: GigaChatAPI,
private val tools: Map,
) {
private val functions: List = tools.map { it.value.fn }
// Чтобы самим не думать об управлении ЖЦ, воспользуемся имеющимся channelFlow
fun run(): Flow = channelFlow {
// TODO: нужно будет резюмировать историю
val conversation = ArrayList<GigaRequest.Message>()
userMessages.collect { userText ->
// Добавляем в историю чата сообщения пользователя
conversation.add(GigaRequest.Message(GigaMessageRole.user, userText))
while (true) { // TODO: защититься от бесконечного цикла
if (!isActive) break
val response: GigaResponse.Chat = withContext(Dispatchers.IO) {
chat(conversation)
}
when (response) {
is GigaResponse.Chat.Ok -> response
is GigaResponse.Chat.Error -> {
// Прерываем работу на ошибках от API Гигачата
send(response.message)
close()
return@collect
}
}
// Добавляем в историю чата сообщения Гигачата
conversation.addAll(response.toRequestMessages())
val toolAwaits = ArrayList<Deferred<GigaRequest.Message>>()
for (ch in response.choices) {
val msg = ch.message
when {
// Обычный текст просто печатаем
msg.content.isNotBlank() && msg.functionsStateId == null -> {
send(msg.content)
}
// Функции выполняем асинхронно
msg.functionCall != null && msg.functionsStateId != null -> {
val deferred = async(Dispatchers.IO) {
executeTool(msg.functionCall)
}
toolAwaits.add(deferred)
}
}
}
if (toolAwaits.isEmpty()) break
conversation.addAll(toolAwaits.awaitAll())
}
}
}
private fun GigaResponse.Chat.Ok.toRequestMessages(): Collection {
return choices.map { ch ->
val msg = ch.message
val content: String = when {
msg.content.isNotBlank() -> msg.content
// В доках написано, что Гигачату нужен stringify json
msg.functionCall != null -> gigaJsonMapper.writeValueAsString(
mapOf("name" to msg.functionCall.name, "arguments" to msg.functionCall.arguments)
)
else -> throw IllegalStateException("Can't get content from ${ch}")
}
GigaRequest.Message(
role = ch.message.role,
content = content,
functionsStateId = msg.functionsStateId
)
}
}
private fun executeTool(functionCall: GigaResponse.FunctionCall): GigaRequest.Message {
val fn = tools[functionCall.name] ?: return GigaRequest.Message(
GigaMessageRole.function, """{"result":"no such function ${functionCall.name}"}"""
)
return fn.invoke(functionCall)
}
private suspend fun chat(conversation: ArrayList): GigaResponse.Chat {
val body = GigaRequest.Chat(
messages = conversation,
functions = functions,
)
return api.message(body)
}
}
Пробуйте запуститься с дебаггером и понаблюдать за ходом работы.
Попробуем написать агента с SDK, пользуясь имеющимися функциями (тулами). Будет видно, что независимо от LLM и способа интеграции (REST API или SDK) в общем-то ничего не меняется.
Весомая причина включения второй LLM — дать возможность читателям ощутить результативность агента. С Гигачат лично у меня ничего не получилось.
Создаем аккаунт на anthropic [37], покупаем API Key. Пользователям из России придется повозиться: для работы с моделью понадобится VPN. У автора получилось оплатить ключ Казахстанской картой.
Ключ нужно положить в переменные окружения.
export ANTHROPIC_API_KEY=sk-ant-api....
К проекту SDK подключается добавлением одной зависимости:
dependencies {
// ...
implementation("com.anthropic:anthropic-java:1.0.0")
}
С Anthropic всё то же самое, что и с Гигачатом, только будем использовать объекты их SDK вместо написанных нами DTO:
interface AnthropicToolSetup {
val tool: Tool
operator fun invoke(toolUse: ToolUseBlock): ToolResultBlockParam
}
import com.anthropic.core.JsonValue
import com.anthropic.core.jsonMapper
import com.anthropic.models.messages.Tool
import com.anthropic.models.messages.ToolResultBlockParam
import com.anthropic.models.messages.ToolUseBlock
import com.dumch.tool.InputParamDescription
import com.dumch.tool.ToolSetup
import kotlin.reflect.KCallable
import kotlin.reflect.full.declaredMembers
import kotlin.reflect.full.findAnnotation
interface AnthropicToolSetup {
val tool: Tool
operator fun invoke(toolUse: ToolUseBlock): ToolResultBlockParam
}
val anthropicJsonMapper = jsonMapper()
inline fun <reified Input> ToolSetup<Input>.toAnthropic(): AnthropicToolSetup {
val toolSetup = this
val inputSchema: Tool.InputSchema = HashMap<String, Any>().let { schema ->
val clazz = Input::class
for (property: KCallable<*> in clazz.declaredMembers) {
// We're not afraid of reflection here — it only runs once at startup and doesn't affect runtime.
val annotation = property.findAnnotation<InputParamDescription>() ?: continue
val description = annotation.value
val type = property.returnType.toString().substringAfterLast(".").lowercase()
val desc = mapOf("type" to type, "description" to description)
schema.put(property.name, desc)
}
Tool.InputSchema.builder()
.properties(JsonValue.from(schema))
.build()
}
return object : AnthropicToolSetup {
override val tool: Tool = Tool.Companion.builder()
.name(toolSetup.name)
.description(toolSetup.description)
.inputSchema(inputSchema)
.build()
override fun invoke(toolUse: ToolUseBlock): ToolResultBlockParam {
try {
val input: JsonValue = toolUse._input()
val typed: Input = anthropicJsonMapper.convertValue(input, Input::class.java)
val result = toolSetup.invoke(typed)
return ToolResultBlockParam.builder()
.content(result)
.toolUseId(toolUse.id())
.isError(false)
.build()
} catch (e: Exception) {
// TODO: proper logging should be implemented
println(e)
return ToolResultBlockParam.Companion.builder()
.content("Unpredicted exception with the tool '$name': ${e.message}")
.isError(true)
.build()
}
}
}
}
Агент 1 в 1, как GigaAgent. Если хотите, можете вынести общую часть в абстракцию. Я этого делать не стал, чтобы не усложнять статью.
import com.anthropic.client.AnthropicClient
import com.anthropic.client.okhttp.AnthropicOkHttpClient
import com.anthropic.models.messages.*
import com.dumch.tool.files.*
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
class AnthropicAgent(
private val client: AnthropicClient,
private val tools: Map,
private val model: Model,
private val userMessages: Flow,
) {
private val anthropicTools: List = tools.map { (_, tool) ->
ToolUnion.ofTool(tool.tool)
}
fun run(): Flow = channelFlow {
// TODO: summarize conversation
val conversation = ArrayList<MessageParam>()
userMessages.collect { userText ->
val userMessageParam = MessageParam.Companion.builder()
.role(MessageParam.Role.USER)
.content(userText)
.build()
conversation.add(userMessageParam)
for (i in 1..MAX_TOOL_ITERATIONS) { // infinite loop protection
if (!isActive) break
val response = withContext(Dispatchers.IO) {
continueChat(conversation)
}
conversation.add(response.toParam())
val toolAwaits = ArrayList<Deferred<ToolResultBlockParam>>()
for (content in response.content()) {
when {
content.isToolUse() ->; {
val deferred = async(Dispatchers.IO) {
executeTool(content.asToolUse())
}
toolAwaits.add(deferred)
}
content.isText() -> send(content.asText().text())
}
}
if (toolAwaits.isEmpty()) break
val toolResults = toolAwaits.awaitAll()
val toolContentBlockParams = toolResults.map(ContentBlockParam.Companion::ofToolResult)
val toolUseResultMessageParam = MessageParam.Companion.builder()
.role(MessageParam.Role.USER)
.content(MessageParam.Content.ofBlockParams(toolContentBlockParams))
.build()
conversation.add(toolUseResultMessageParam)
}
}
}
private fun executeTool(toolBlock: ToolUseBlock): ToolResultBlockParam {
val name = toolBlock.name()
val tool = tools[name] ?: return ToolResultBlockParam.Companion.builder()
.content("Tool $name not found")
.isError(true)
.build()
return tool.invoke(toolBlock)
}
private fun continueChat(conversation: List): Message {
val paramsBuilder = MessageCreateParams.Companion.builder()
.model(model)
.maxTokens(1024)
.temperature(1.0)
.messages(conversation)
paramsBuilder.tools(anthropicTools)
return client.messages().create(paramsBuilder.build())
}
companion object {
private val MAX_TOOL_ITERATIONS = 10
fun instance(
userInputFlow: Flow,
model: Model = Model.CLAUDE_3_5_SONNET_20241022,
): AnthropicAgent {
val client: AnthropicClient = AnthropicOkHttpClient.fromEnv()
val tools: Map = listOf(
ToolReadFile.toAnthropic(),
ToolListFiles.toAnthropic(),
ToolNewFile.toAnthropic(),
ToolDeleteFile.toAnthropic(),
ToolModifyFile.toAnthropic(),
ToolFindTextInFiles.toAnthropic(),
).associateBy { it.tool.name() }
return AnthropicAgent(client, tools, model, userInputFlow)
}
}
}
И запуск:
import com.dumch.anth.AnthropicAgent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
private const val AGENT_ALIAS = "🪐"
suspend fun main() {
val agent = AnthropicAgent.instance(userInputFlow())
agent.run().collect { text -> print("$AGENT_ALIAS: $text") }
}
private fun userInputFlow(): Flow = flow {
println("Type `exit` to quit")
while (true) {
print("> ")
val input = readLine() ?: break
if (input.lowercase() == "exit") break
emit(input)
println("n")
}
}
А дальше — самое интересное. Попробуйте, используя агента, дописать другие функции. Например, я попросил написать функцию для терминала вот таким промптом:
Similar with what I already have, help me implement a tool that is capable of using bash. For example, ls, echo, find, ./gradlew commands
И вот что получил:
package com.dumch.tool
import java.io.BufferedReader
import java.io.InputStreamReader
object ToolRunBashCommand : ToolSetup {
override val name = "RunBashCommand"
override val description = "Executes a bash command and returns its output"
override fun invoke(input: Input): String {
val process = ProcessBuilder("bash", "-c", input.command)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().use(BufferedReader::readText)
val exitCode = process.waitFor()
if (exitCode != 0) throw RuntimeException("Command failed with exit code $exitCode")
return output.trim()
}
data class Input(
@InputParamDescription("The bash command to run, e.g., 'ls', 'echo Hello', './gradlew tasks'")
val command: String
)
}
Антропик написал такую реализацию, которая позволит ему украсть наши ключи. Лучше ограничить список команд, которые он может выполнять. Для начала можно обойтись одной: ./gradlew.
class ToolRunBashCommandTest {
@Test
fun `test ls command execution`() {
// Execute the ls command
val result = ToolRunBashCommand.invoke(ToolRunBashCommand.Input("ls"))
// Verify the result contains some common files/directories
assertTrue(result.contains("src"), "Output should contain 'src' directory")
assertTrue(result.contains("build.gradle.kts"), "Output should contain 'build.gradle.kts' file")
}
}
Не забудьте реализовать суммаризацию диалога с чатом, чтобы и токены сэкономить и помещаться в контекстное окно.
// ... inside AnthropicAgent
private suspend fun trySummarize(conversation: ArrayList) {
val msg = MessageCountTokensParams.builder().model(model).messages(conversation).build()
val inputTokens: Long = client.messages().countTokens(msg).inputTokens()
if (inputTokens > MAX_TOKENS * THRESHOLD_PCT /* 8096 */) return
val summary = withContext(Dispatchers.IO) {
client.messages().create(
MessageCreateParams.builder()
.model(model)
.temperature(0.7)
.messages(conversation)
.system("Summarize the conversation so far")
.build()
)
}
val lastMessage = conversation.last()
conversation.clear()
conversation.add(summary.toParam())
conversation.add(lastMessage)
}
Хорошо бы добавить обработку ошибок. Не просто завершать программу, но сохранить имеющийся «разговор» на диск, вдруг понадобится.
Не помешает обертка над GitHub API [38], чтобы Агент мог смотреть код открытых проектов. Или пойти в сторону Web Scraping, что будет посложнее.
Инфраструктурно явно не хватает логов для понимая, что происходит.
Если захочется встроить агента в редактор кода, хороший вариант — реализовать LSP-сервер [39].
Как видите, написать агента несложно, сложно оплачивать счета за Anthropic.
Автор: arturdumchev
Источник [40]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/17609
URLs in this post:
[1] восприятие: http://www.braintools.ru/article/7534
[2] REPL: https://en.wikipedia.org/wiki/Read%E2%80%93eval%E2%80%93print_loop
[3] Что такое агент: #what_is_agent
[4] Как работают агенты: пример в чате: #how_agent_works
[5] Как работают агенты: пример с API: #how_agent_api_works
[6] Реализация функций агента: #writing_functions
[7] Контракт функций: #fn_contract
[8] Пишем первую функцию — ListFiles: #list_files
[9] Пишем функцию чтения файла: #read_file
[10] Остальные функции работы с файловой системой: #other_functions
[11] Заботимся о безопасности: #security
[12] Реализация агента с Гигачатом: #giga_agent_impl
[13] Чат с агентом-попугаем: #parrot_agent
[14] Гигачат по REST API. Запрос токена: #giga_rest_token
[15] Гигачат по REST API. Общение с LLM: #giga_rest_llm
[16] Подключаем функций: #giga_fn_setup
[17] Реализация агента: #giga_agent_impl_code
[18] Реализация агента через Anthropic SDK: #anthropic_agent
[19] Подготовка: #anthropic_agent_setup
[20] Адаптер над функциями и агент: #anthropic_agent_tools_and_agent
[21] Что дальше?: #what_next
[22] память: http://www.braintools.ru/article/4140
[23] RAG: https://habr.com/ru/companies/raft/articles/791034/
[24] функция: https://developers.sber.ru/docs/ru/gigachat/guides/function-calling#rabota-s-sobstvennymi-funktsiyami
[25] Гигачата: https://developers.sber.ru/portal/products/gigachat-api
[26] инструкции: https://www.sberbank.com/ru/certificates
[27] документации: https://developers.sber.ru/docs/ru/gigachat/api/reference/rest/post-chat
[28] KOSMOS-agent: https://github.com/D00mch/ko-agent/tree/skeleton
[29] внимание: http://www.braintools.ru/article/7595
[30] обучения: http://www.braintools.ru/article/5125
[31] DFS: https://en.wikipedia.org/wiki/Depth-first_search
[32] преобразованием коллекций: https://kotlinlang.org/docs/collection-transformations.html
[33] KOSMOS-agent: https://github.com/D00mch/KOSMOS-agent/tree/tutorial
[34] Ktor: https://ktor.io/docs/client-create-new-application.html
[35] kotlinlang.org: http://kotlinlang.org
[36] KOSMOS-agent: https://github.com/D00mch/KOSMOS-agent/tree/tutorial/src/main/kotlin/tool/files
[37] anthropic: https://console.anthropic.com/dashboard
[38] GitHub API: https://docs.github.com/en/rest/orgs?apiVersion=2022-11-28
[39] LSP-сервер: https://microsoft.github.io/language-server-protocol/
[40] Источник: https://habr.com/ru/articles/930524/?utm_source=habrahabr&utm_medium=rss&utm_campaign=930524
Нажмите здесь для печати.