Выполнение кода (программный вызов инструментов)

Инструмент execute_code даёт агенту возможность писать Python-скрипты, которые программно вызывают инструменты Hermes. Несколько шагов сворачиваются в один ход LLM. Скрипт работает в дочернем процессе на хосте агента и общается с Hermes через RPC поверх Unix-сокета.

Как это работает

  1. Агент пишет Python-скрипт, используя from hermes_tools import ...
  2. Hermes генерирует модуль-заглушку hermes_tools.py с RPC-функциями
  3. Hermes открывает Unix-сокет и запускает поток-слушатель RPC
  4. Скрипт выполняется в дочернем процессе — вызовы инструментов уходят по сокету обратно в Hermes
  5. В LLM возвращается только то, что скрипт вывел через print(); промежуточные результаты инструментов в окно контекста не попадают
# Агент может писать скрипты вроде:
from hermes_tools import web_search, web_extract

results = web_search("Python 3.13 features", limit=5)
for r in results["data"]["web"]:
    content = web_extract([r["url"]])
    # ... фильтруем и обрабатываем ...
print(summary)

Какие инструменты доступны внутри скриптов: web_search, web_extract, read_file, write_file, search_files, patch, terminal (только в режиме переднего плана).

Когда агент к этому прибегает

Агент берётся за execute_code, если в задаче есть:

  • 3+ вызова инструментов с логикой обработки между ними
  • массовая фильтрация данных или ветвление по условию
  • циклы по результатам

Главный выигрыш — промежуточные результаты инструментов не попадают в окно контекста: назад возвращается лишь итоговый вывод print(). Расход токенов падает в разы.

Практические примеры

Конвейер обработки данных

from hermes_tools import search_files, read_file
import json

# Находим все конфиги и вытаскиваем настройки базы данных
matches = search_files("database", path=".", file_glob="*.yaml", limit=20)
configs = []
for match in matches.get("matches", []):
    content = read_file(match["path"])
    configs.append({"file": match["path"], "preview": content["content"][:200]})

print(json.dumps(configs, indent=2))

Многошаговое веб-исследование

from hermes_tools import web_search, web_extract
import json

# Поиск, извлечение и сводка за один ход
results = web_search("Rust async runtime comparison 2025", limit=5)
summaries = []
for r in results["data"]["web"]:
    page = web_extract([r["url"]])
    for p in page.get("results", []):
        if p.get("content"):
            summaries.append({
                "title": r["title"],
                "url": r["url"],
                "excerpt": p["content"][:500]
            })

print(json.dumps(summaries, indent=2))

Массовый рефакторинг файлов

from hermes_tools import search_files, read_file, patch

# Находим все Python-файлы с устаревшим API и правим их
matches = search_files("old_api_call", path="src/", file_glob="*.py")
fixed = 0
for match in matches.get("matches", []):
    result = patch(
        path=match["path"],
        old_string="old_api_call(",
        new_string="new_api_call(",
        replace_all=True
    )
    if "error" not in str(result):
        fixed += 1

print(f"Fixed {fixed} files out of {len(matches.get('matches', []))} matches")

Сборка и прогон тестов

from hermes_tools import terminal, read_file
import json

# Запускаем тесты, разбираем результат и формируем отчёт
result = terminal("cd /project && python -m pytest --tb=short -q 2>&1", timeout=120)
output = result.get("output", "")

# Парсим вывод тестов
passed = output.count(" passed")
failed = output.count(" failed")
errors = output.count(" error")

report = {
    "passed": passed,
    "failed": failed,
    "errors": errors,
    "exit_code": result.get("exit_code", -1),
    "summary": output[-500:] if len(output) > 500 else output
}

print(json.dumps(report, indent=2))

Режим выполнения

У execute_code два режима выполнения, ими управляет параметр code_execution.mode в ~/.hermes/config.yaml:

РежимРабочая директорияИнтерпретатор Python
project (по умолчанию)Рабочая директория сессии (та же, что и у terminal())Python из активного VIRTUAL_ENV / CONDA_PREFIX, при их отсутствии — собственный python Hermes
strictВременная служебная директория, изолированная от проекта пользователяsys.executable (собственный python Hermes)

Когда оставить project: вы хотите, чтобы import pandas, from my_project import foo или относительные пути вроде open(".env") работали так же, как в terminal(). В подавляющем большинстве случаев нужно именно это.

Когда переключиться на strict: вам важна максимальная воспроизводимость — один и тот же интерпретатор в каждой сессии, независимо от того, какой venv активировал пользователь, плюс изоляция скриптов от дерева проекта (нельзя случайно прочитать файл проекта по относительному пути).

# ~/.hermes/config.yaml
code_execution:
  mode: project   # or "strict"

Как ведёт себя откат в режиме project: если VIRTUAL_ENV / CONDA_PREFIX не задан, сломан или указывает на Python старше 3.8, резолвер аккуратно переключается на sys.executable. Без рабочего интерпретатора агент не остаётся никогда.

Критичные для безопасности инварианты в обоих режимах одинаковы:

  • очистка окружения (API-ключи, токены, учётные данные вырезаются)
  • белый список инструментов (скрипты не могут рекурсивно вызывать execute_code, delegate_task или MCP-инструменты)
  • ограничения ресурсов (таймаут, потолок stdout, потолок числа вызовов инструментов)

Смена режима меняет, где запускаются скрипты и какой интерпретатор их исполняет, но не то, какие учётные данные им видны и какие инструменты они могут вызывать.

Ограничения ресурсов

РесурсЛимитПримечания
Таймаут5 минут (300 с)Скрипт убивается через SIGTERM, затем SIGKILL спустя 5 с отсрочки
Stdout50 КБВывод обрезается с пометкой [output truncated at 50KB]
Stderr10 КБДобавляется в вывод при ненулевом коде выхода — для отладки
Вызовы инструментов50 на один запускПри достижении лимита возвращается ошибка

Все лимиты настраиваются через config.yaml:

# In ~/.hermes/config.yaml
code_execution:
  mode: project      # project (default) | strict
  timeout: 300       # Max seconds per script (default: 300)
  max_tool_calls: 50 # Max tool calls per execution (default: 50)

Как работают вызовы инструментов внутри скриптов

Когда ваш скрипт вызывает функцию вроде web_search("query"), происходит вот что:

  1. Вызов сериализуется в JSON и отправляется по Unix-сокету родительскому процессу
  2. Родитель диспетчеризует его через штатный обработчик handle_function_call
  3. Результат отправляется обратно по сокету
  4. Функция возвращает разобранный результат

То есть вызовы инструментов внутри скриптов ведут себя ровно так же, как обычные: те же лимиты частоты, та же обработка ошибок, те же возможности. Единственное ограничение — terminal() доступен только на переднем плане (без параметров background и pty).

Обработка ошибок

Когда скрипт падает, агент получает структурированные сведения об ошибке:

  • Ненулевой код выхода: stderr добавляется в вывод, поэтому агент видит полный traceback
  • Таймаут: скрипт убивается, и агент видит "Script timed out after 300s and was killed."
  • Прерывание: если во время выполнения пользователь присылает новое сообщение, скрипт завершается, а агент получает [execution interrupted — user sent a new message]
  • Лимит вызовов инструментов: при достижении 50 вызовов все последующие возвращают сообщение об ошибке

В ответе всегда есть status (success/error/timeout/interrupted), output, tool_calls_made и duration_seconds.

Безопасность

опасно
Модель безопасности

Дочерний процесс запускается с минимальным окружением. API-ключи, токены и учётные данные по умолчанию вырезаются. Скрипт работает с инструментами исключительно через RPC-канал — прочитать секреты из переменных окружения он не может, если это не разрешено явно.

Переменные окружения, в имени которых есть KEY, TOKEN, SECRET, PASSWORD, CREDENTIAL, PASSWD или AUTH, исключаются. Пробрасываются только безопасные системные переменные (PATH, HOME, LANG, SHELL, PYTHONPATH, VIRTUAL_ENV и т. п.).

Проброс переменных окружения навыка

Если навык объявляет в своём frontmatter required_environment_variables, после загрузки навыка эти переменные автоматически пробрасываются в дочерние процессы и execute_code, и terminal. Так навыки получают свои объявленные API-ключи, не ослабляя при этом защиту для произвольного кода.

Для случаев вне навыков переменные можно явно добавить в список разрешений в config.yaml:

terminal:
  env_passthrough:
    - MY_CUSTOM_KEY
    - ANOTHER_TOKEN

Подробности — в руководстве по безопасности.

Переменные HERMES_* в дочернем процессе

Дочерний процесс получает лишь небольшой фиксированный набор операционных переменных HERMES_*, причём по точному имени:

  • HERMES_HOME
  • HERMES_PROFILE
  • HERMES_CONFIG
  • HERMES_ENV

(плюс HERMES_RPC_DIR / HERMES_RPC_SOCKET / TZ / HOME, которые Hermes подставляет явно, чтобы работал RPC-канал).

заметка
Изменение поведения

Прежние версии пробрасывали в дочерний процесс любую переменную, имя которой начиналось с HERMES_. Этот широкий префикс убрали ради усиления безопасности: он мог утянуть в произвольный песочный код конфигурацию с именем вида HERMES_*, не совпадающую ни с одной секретной подстрокой (например HERMES_BASE_URL, HERMES_KANBAN_DB или эндпоинт HERMES_*_WEBHOOK).

Если скрипт execute_code — или модуль репозитория/плагина, который он импортирует на этапе импорта — полагался на переменную HERMES_* вне четырёх операционных имён выше, теперь она окажется в дочернем процессе неустановленной. Это убрано намеренно, а не баг.

Обходной путь — явно вернуть переменную в проброс. Оба способа пробрасывают переменную в дочерние процессы и execute_code, и terminal, и ни один не ослабляет гарантию вырезания секретов (учётные данные провайдеров, которыми управляет Hermes, так вернуть нельзя):

  1. Для конкретной машины, в config.yaml — добавьте точное имя переменной в список разрешений проброса:

    terminal:
      env_passthrough:
        - HERMES_KANBAN_DB
        - HERMES_BASE_URL
  2. Для конкретного навыка, в его frontmatter — объявите её, чтобы она регистрировалась автоматически при каждой загрузке навыка:

    required_environment_variables:
      - HERMES_KANBAN_DB

Как диагностировать. Когда дочерний процесс отбрасывает одну или несколько переменных HERMES_* не из списка разрешений, Hermes пишет однострочный debug-лог с их именами и подсказкой про лазейку env_passthrough. Запустите с отладочным логированием (hermes logs --level DEBUG или загляните в ~/.hermes/logs/agent.log) и ищите строку execute_code: dropped N non-allowlisted HERMES_* var(s), если скрипт ведёт себя так, будто переменной HERMES_* не хватает.

Сам скрипт и автосгенерированную RPC-заглушку hermes_tools.py Hermes всегда пишет во временную служебную директорию, которая удаляется после выполнения. В режиме strict скрипт там же и выполняется; в режиме project он работает в рабочей директории сессии (служебная директория остаётся в PYTHONPATH, поэтому импорты по-прежнему разрешаются). Дочерний процесс запускается в собственной группе процессов — это позволяет чисто прибить его при таймауте или прерывании.

execute_code против terminal

Сценарийexecute_codeterminal
Многошаговые сценарии с вызовами инструментов между шагами
Простая команда shell
Фильтрация/обработка большого вывода инструментов
Запуск сборки или набора тестов
Цикл по результатам поиска
Интерактивные/фоновые процессы
Нужны API-ключи в окружении⚠️ Только через проброс✅ (большинство пробрасывается)

Эмпирическое правило: берите execute_code, когда нужно программно вызывать инструменты Hermes с логикой между вызовами. Для команд shell, сборок и процессов используйте terminal.

Поддержка платформ

Выполнению кода нужны Unix-сокеты, поэтому оно доступно только на Linux и macOS. На Windows функция автоматически отключается — агент откатывается к обычным последовательным вызовам инструментов.

ESC