Блог Половнёва

Кодислав — 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, дать описание тула, описать его параметры и запрограммировать работу с ними.

Дальше это будет работать вот так:

  1. Когда мы начинаем диалог с ЛЛМкой, RubyLLM дополнительно подсовывает ей список доступных тулов с описаниями.
  2. Пользователь отправляет сообщение ЛЛМке.
  3. ЛЛМке решает, что ей нужно вызвать тул.
  4. ЛЛМка возвращает специальным сообщением с вызовом тула: имя, извлеченные параметры.
  5. RubyLLM вызывает тул с полученными параметрами.
  6. Результат вызова RubyLLM возвращает ЛЛМке.
  7. ЛЛМка использует полученный результат и генерирует ответ пользователю.

Начнём с того, что дадим ей тупейший инструмент для получения содержимого папки:

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. Ещё больше постов о программировании, тестах и культуре разработки у меня в Телеграме.