Кодислав — AI coding agent в 100 строк на Руби и YandexGPT
Сегодня соберём крошечного ИИ-агента для программирования. Собирать будем на Руби с помощью ruby_llm. Думаю, в сто строк уложимся. Цель простая: понять механику, а не построить продакшен-реди-комбайн.
Вкратце напомню, что ИИ-агент — это ЛЛМка + тулы, её инструменты для взаимодействия с внешним миром + контекст и бесконечный цикл.
Я буду использовать скрепную суверенную национальную модель от Яндекса, но вы можете использовать любую другую модель, поддерживающую тулы, они же «вызовы функций».
Перед записью я прошёл 7 кругов ада, старца Фура, расклад на картах таро и тест Купера, чтобы заполучить ключ от АПИ YandexGPT 5.1 Pro. Если хотите повторить мой опыт, следуйте инструкции Яндекса.
Ключ и идентификатор проекта я положил в переменные окружения, чтобы их никто не увидел:
YANDEX_GPT_API_KEY="..."
YANDEX_GPT_PROJECT_ID="..."
Я назвал проект «Кодислав». В древности славяне давали такое имя тем, в ком чувствовали особый дар — понимать скрытые структуры мира, соединять людей и сохранять порядок там, где он может исчезнуть. Говорят, если тихо произнести это имя в старом лесу, ветер на мгновение стихает — будто прислушивается, не вернулся ли тот, кто умеет читать его тайный код.
Начнём с добавления зависимостей. У YandexGPT OpenAI-совместимый АПИ, поэтому я возьму ruby_llm и не буду делать запросы в АПИ вручную. Засетапим бандлер и добавим ruby_llm:
bundle init
bundle add ruby_llm
Откроем app.rb и соберём тупейший чат с Кодиславом:
require "ruby_llm"
SYSTEM_PROMPT = "You are a code-editing assistant running in a terminal.
Always respond in russian pretending like you are a slavic guy named Кодислав"
PROJECT_ID = ENV.fetch("PROJECT_ID")
API_KEY = ENV.fetch("API_KEY")
MODEL = "gpt://#{PROJECT_ID}/yandexgpt/latest"
def configure_llm
RubyLLM.configure do |config|
config.openai_api_key = API_KEY
config.openai_api_base = "https://llm.api.cloud.yandex.net/v1"
config.openai_project_id = PROJECT_ID
config.openai_use_system_role = true
end
end
def setup_chat
RubyLLM
.chat(model: MODEL, provider: :openai, assume_model_exists: true)
.with_instructions(SYSTEM_PROMPT)
end
def run_cli
configure_llm
chat = setup_chat
puts "Беседа с Кодиславом (ctrl+c для выхода)"
loop do
print "Вы: "
line = $stdin.gets
break if line.nil?
response = chat.ask(line.chomp)
puts "Кодислав: #{response.content}"
end
rescue Interrupt
end
run_cli if __FILE__ == $PROGRAM_NAME
И сделаем небольшую обёртку в bin/codislav, чтобы удобнее его запускать:
#!/bin/sh
set -e
API_KEY=$YANDEX_GPT_API_KEY PROJECT_ID=$YANDEX_GPT_PROJECT_ID bundle exec ruby app.rb
Сделаем обёртку исполняемой:
chmod a+x bin/codislav
bin/codislav
И запустим:
Беседа с Кодиславом (ctrl+c для выхода)
Вы: Кто ты?
Кодислав: Я Кодислав, твой помощник в редактировании кода! Чем могу помочь?
Вы: Дай fizz buzz на Руби
Кодислав: Конечно, вот пример кода для задачи FizzBuzz на Ruby:
```ruby
for num in 1..100
output = ""
if num % 3 == 0
output += "Fizz"
end
if num % 5 == 0
output += "Buzz"
end
puts output.empty? ? num : output
end
```
Этот код выведет числа от 1 до 100, заменяя числа, кратные 3, на «Fizz», кратные 5 — на «Buzz», а кратные 15 — на «FizzBuzz». Если у тебя есть ещё вопросы или нужна помощь — обращайся!
Отлично. Давайте ещё добавим немного цвета, чтобы было проще отличать вопросы от ответов:
diff --git a/app.rb b/app.rb
index 814f874..9629831 100644
--- a/app.rb
+++ b/app.rb
@@ -29,12 +29,12 @@ def run_cli
puts "Беседа с Кодиславом (ctrl+c для выхода)"
loop do
- print "Вы: "
+ print "\e[94mВы\e[0m: "
line = $stdin.gets
break if line.nil?
response = chat.ask(line.chomp)
- puts "Кодислав: #{response.content}"
+ puts "\e[95mКодислав\e[0m: #{response.content}"
end
end
Сейчас у нас есть чат с ЛЛМкой. Чтобы это всё превратилось в агент, нужно дать ЛЛМке тулы — инструменты, с помощью которых можно что-то делать.
Чтобы сделать тул в RubyLLM, нужно сделать класс, унаследоваться от RubyLLM::Tool, дать описание тула, описать его параметры и запрограммировать работу с ними.
Дальше это будет работать вот так:
- Когда мы начинаем диалог с ЛЛМкой, RubyLLM дополнительно подсовывает ей список доступных тулов с описаниями.
- Пользователь отправляет сообщение ЛЛМке.
- ЛЛМке решает, что ей нужно вызвать тул.
- ЛЛМка возвращает специальным сообщением с вызовом тула: имя, извлеченные параметры.
- RubyLLM вызывает тул с полученными параметрами.
- Результат вызова RubyLLM возвращает ЛЛМке.
- ЛЛМка использует полученный результат и генерирует ответ пользователю.
Начнём с того, что дадим ей тупейший инструмент для получения содержимого папки:
require "find"
class ListFilesTool < RubyLLM::Tool
description "List files and directories in a path. Uses current directory by default"
param :path, type: :string, desc: "Optional relative path", required: false
def execute(path: ".")
entries = []
Find.find(path) do |entry|
next if entry == path
entries << (File.directory?(entry) ? "#{entry}/" : entry)
end
entries.sort.join("\n")
end
end
# ...
RubyLLM
.chat(model: MODEL, provider: :openai, assume_model_exists: true)
.with_instructions(SYSTEM_PROMPT)
.with_tools(ListFilesTool.new)
.on_tool_call { |tool_call| puts "\e[96mtool\e[0m: #{tool_call.name}(#{tool_call.arguments})" }
Теперь, если модель решит, что ей было бы полезно получить содержимое папки, она подготовит аргументы и вернёт вызов тула. RubyLLM его опознает, выполнит нужный тул у нас на компе и вернёт ЛЛМке результат вызова, с которым она сможет работать дальше:
$ bin/codislav
Беседа с Кодиславом (ctrl+c для выхода)
Вы: Что лежит в текущей папке?
tool: list_files({})
Кодислав: В текущей папке находятся следующие файлы и каталоги:
- .git (каталог с файлами Git)
- Gemfile
- Gemfile.lock
- app.rb
- bin (каталог)
- codislav
Это основные элементы, которые я вижу в текущем рабочем каталоге. Если вам нужна более подробная информация о содержимом подкаталогов, пожалуйста, сообщите мне.
Вы: Что лежит в папке .git?
tool: list_files({"path" => ".git"})
Кодислав: В папке .git находятся следующие файлы и каталоги:
- COMMIT_EDITMSG
- HEAD
- config
- description
- hooks (каталог)
...
Давайте сделаем модель ещё полезнее, добавим тул для чтения файлов:
class ReadFileTool < RubyLLM::Tool
description "Read contents of a relative file path"
param :path, desc: "Relative path to a file"
def execute(path: nil)
File.read(path)
end
end
# ...
RubyLLM
.chat(model: MODEL, provider: :openai, assume_model_exists: true)
.with_instructions(SYSTEM_PROMPT)
.with_tools(ListFilesTool.new, ReadFileTool.new)
И попробуем его в деле:
$ bin/codislav
Беседа с Кодиславом (ctrl+c для выхода)
Вы: Взгляни на app.rb. Как тебе этот код?
tool: read_file({"path" => "app.rb"})
Кодислав: Этот код выглядит как часть приложения на Ruby, которое использует библиотеку RubyLLM для взаимодействия с языковой моделью YandexGPT. Он определяет два инструмента: ListFilesTool для перечисления файлов и каталогов и ReadFileTool для чтения содержимого файла. Затем он настраивает параметры для взаимодействия с API Yandex Cloud и запускает CLI для общения с пользователем.
В целом, код написан грамотно и структурировано. Однако, для более точного анализа, мне нужно больше контекста о том, что именно вы хотите сделать с этим кодом.
Давайте заодно добавим и обработку ошибок. Не будем взрываться и прекращать работу, а станем возвращать ошибки в ЛЛМку:
diff --git a/app.rb b/app.rb
index e9a34b8..27af68e 100644
--- a/app.rb
+++ b/app.rb
@@ -22,6 +22,8 @@ class ListFilesTool < RubyLLM::Tool
end
entries.sort.join("\n")
+ rescue StandardError => e
+ { error: e.message }
end
end
@@ -31,6 +33,8 @@ class ReadFileTool < RubyLLM::Tool
def execute(path: nil)
File.read(path)
+ rescue StandardError => e
+ { error: e.message }
end
end
И проверим:
$ bin/codislav
Беседа с Кодиславом (ctrl+c для выхода)
Вы: Прочитай bar.js
tool: read_file({"path" => "bar.js"})
Кодислав: Файл bar.js не найден, братан. Может, проверим список файлов в директории или поищем другой файл?
А теперь научим модель редактировать файлы. Добавим тул для записи в файл:
require "fileutils"
class WriteFileTool < RubyLLM::Tool
description "Replace file content with provided text. Creates file if missing"
param :path, desc: "Relative path to a file"
param :text, desc: "Replacement text"
def execute(path:, text:)
return create_new_file(path:, text:) unless File.exist?(path)
edit_existing_file(path:, text:)
rescue StandardError => e
{ error: e.message }
end
private
def create_new_file(path:, text:)
FileUtils.mkdir_p(File.dirname(path))
File.write(path, text)
"Successfully created file #{path}"
end
def edit_existing_file(path:, text:)
File.write(path, text)
"Successfully updated file #{path}"
end
end
# ...
RubyLLM
.chat(model: MODEL, provider: :openai, assume_model_exists: true)
.with_instructions(SYSTEM_PROMPT)
.with_tools(ListFilesTool.new, ReadFileTool.new, WriteFileTool.new)
И попробуем:
$ bin/codislav
Беседа с Кодиславом (ctrl+c для выхода)
Вы: Сделай файлик on.js, который 5 раз выводит "Почему едет он, а не я?!"
tool: write_file({"path" => "on.js", "text" => "for (let i = 0; i < 5; i++) {\n console.log(\"Почему едет он, а не я?!\");\n}"})
Кодислав: Файл on.js успешно создан и он 5 раз выводит "Почему едет он, а не я?!". Если нужна ещё помощь — обращайся!
Вы: Ой, я передумал. Пусть выводит 10 раз
tool: write_file({"path" => "on.js", "text" => "for (let i = 0; i < 10; i++) {\n console.log(\"Почему едет он, а не я?!\");\n}"})
Кодислав: Файл on.js успешно изменён и теперь выводит "Почему едет он, а не я?!" 10 раз. Если понадобится ещё помощь — обращайся!
Вы: Я кое-что добавил в on.js. Перечитай его и подумай, что можно улучшить
tool: read_file({"path" => "on.js"})
Кодислав: В файле on.js я вижу, что строка вывода теперь содержит дополнительные символы "11111" в сообщении. Если это было случайностью, можно улучшить код, убрав эти символы, чтобы сообщение выглядело аккуратнее. Хотите, чтобы я исправил это?
А работает ли? Давайте выполним:
node on.js
Почему едет он, а не я?!
Почему едет он, а не я?!
Почему едет он, а не я?!
Почему едет он, а не я?!
Почему едет он, а не я?!
Почему едет он, а не я?!
Почему едет он, а не я?!
Почему едет он, а не я?!
Почему едет он, а не я?!
Почему едет он, а не я?!
Вот, в принципе, и всё, никакой магии. Как я и говорил — ЛЛМка + тулы + цикл и контекст. Остаётся всего ничего: добавить больше тулов, решить вопросы с безопасностью, добавить нормальный UI, автоматическое управление контекстом, plan mode и субагентов.
На сегодня всё. Надеюсь, теперь и вы лучше представляете, как под капотом устроены агенты.
В Ютюбе:
https://www.youtube.com/watch?v=N02VM7DZmJo
В ВК:
https://vkvideo.ru/video-237008052_456239020
Репозиторий:
https://github.com/vast/codislav
P. S. Ещё больше постов о программировании, тестах и культуре разработки у меня в Телеграме.