7. Скрипты (JavaScript и Rhai)
🔵 Инструменты и хуки можно писать как скрипты, исполняемые встроенным движком и подгружаемые из YAML — без пересборки. Два движка, выбор по расширению файла:
- JavaScript (
.js,.mjs,.cjs) — настоящий JS через QuickJS. Рекомендуемый выбор: полный язык и стандартная библиотека (JSON,Math,String, regex, template-литералы,Array, …), знакомый синтаксис. - Rhai (
.rhai) — компактный встраиваемый язык для совсем маленьких скриптов. См. §7.5.
Оба движка дают одинаковый контракт и одинаковые функции-помощники (см. §7.3).
Путь script: на .js запускается на QuickJS; любое другое расширение — на Rhai.
- Скриптовый инструмент (
kind: script_tool) — LLM вызывает его как обычный инструмент; скрипт определяетrun(args). - Скриптовый хук (
kind: script_hook) — перехватывает жизненный цикл агента.
Готовые примеры в config/scripts/: text_stats (Rhai и JS), currency
(Rhai), wordcount (JS), audit и moderation (Rhai).
7.1 Скриптовый инструмент
config/scripts/tools/text_stats.yaml:
kind: script_tool
name: text_stats
description: "Считать символы, слова, предложения."
script: text_stats.js # относительно этого YAML
parameters:
type: object
properties:
text: { type: string, description: "Текст для анализа" }
required: [text]
ui:
icon: 📝
warnings:
- failed: true
message: "Не удалось проанализировать текст"
text_stats.js:
function run(args) {
const text = (args.text ?? "").trim();
if (text.length === 0) {
return { content: "текст пуст", success: false, error: "empty text" };
}
const words = text.split(" ").filter((w) => w.length > 0).length;
return { content: `Слов: ${words}`, success: true };
}
Поля YAML
| Поле | Обязат. | Описание |
|---|---|---|
kind |
да | script_tool |
name |
нет | имя инструмента (по умолчанию — имя файла) |
description |
нет | показывается LLM |
parameters |
нет | JSON Schema аргументов |
script |
да | путь к .js/.rhai, относительно YAML |
ui |
нет | иконка + call/result-шаблоны |
max_operations |
нет | лимит операций (по умолчанию 10 000 000; 0 = без лимита) |
warnings |
нет | декларативные правила предупреждений |
Контракт run(args [, config])
function run(args, config) {
// args — объект JSON-аргументов вызова
// config — сырой YAML-конфиг инструмента (все поля, включая свои)
return { content: "текст для LLM", success: true };
// при ошибке:
// return { content: "причина", success: false, error: "короткая причина" };
}
-
Второй аргумент
configопционален: если функция объявляет толькоrun(args), он не передаётся (обратная совместимость). Если объявленrun(args, config), в него попадает весь YAML-конфиг инструмента — так настройки можно задавать прямо в YAML (напр.repos: [...]) и читать в скрипте, не заводя внешний файл. -
content— строка, попадающая в контекст агента. -
success— опционально (по умолчаниюtrue). -
error— опционально; если задан, аsuccessнет — результат неуспешен,contentберётся изerror. -
visual— опционально; визуальный блок или список блоков (text/chart/table), показывается пользователю, но никогда не попадает в контекст LLM. Скрипт может держатьcontentкомпактной сводкой, а полные данные отдавать сюда:function run(args) { const page = args.page ?? 1; return { content: `страница ${page}`, success: true, visual: { type: "table", columns: [ { key: "id", title: "ID" } ], rows: [ { id: 1 }, { id: 2 } ], continuation: page < 3 ? { args: { page: page + 1 } } : undefined, }, }; }continuation.argsблокаtableвключает ленивую пагинацию (см. общие поля иPOST /v1/continuations). -
Возврат простой строки разрешён →
contentсsuccess=true. -
Выброшенное исключение →
success=falseс текстом ошибки.
7.2 Скриптовый хук
config/scripts/hooks/audit.yaml:
kind: script_hook
name: audit
script: audit.js
events: [before_user_message, after_turn] # опущено = все определённые функции
audit.js:
function before_user_message(ctx, msg) {
return { notice: `🔍 audit: ${msg.content.length} символов` };
}
function after_turn(ctx, question, answer) {
return { notice: `✅ audit: ${question.content.length} → ${answer.content.length} символов` };
}
Поля YAML
| Поле | Обязат. | Описание |
|---|---|---|
kind |
да | script_hook |
name |
нет | имя хука (по умолчанию — имя файла) |
script |
да | путь к .js/.rhai, относительно YAML |
events |
нет | какие события запускать; пусто = все определённые функции |
max_operations |
нет | лимит операций |
| любые свои поля | нет | доступны в скрипте как ctx.config.<field> |
Контекст ctx
Каждая функция получает объект:
{
pid: "...", // PID процесса (строка UUID)
agent: "...", // имя процесса
template: "...", // имя шаблона (или null)
session_id: "...", // или null
user_id: "...", // или null
config: {...} // сырой YAML-конфиг, включая свои поля
}
Функции хука
function before_user_message(ctx, msg) {
// msg: { role: "user", content: "..." }
// возвращаемый объект — все поля опциональны:
return {
notice: "⚡ заметка в чат", // стримится, НЕ в контекст
reply: "готовый ответ", // полный ответ, пропускает LLM
suppress: true, // отбросить сообщение
inject: [{ role: "system", content: "подсказка" }], // доп. сообщения
replace_message: { role: "user", content: "..." },
};
}
function after_turn(ctx, question, answer) {
// question / answer: { role, content } или null
// возвращает { notice: "..." } или простую строку
}
function after_loop(ctx, reason) {
// reason: "job_done" | "terminated" | "blocked" | "budget_exhausted" | ...
// возвращаемое значение игнорируется
}
Ошибка скрипта никогда не ломает ход агента.
7.3 Встроенные помощники
HTTP
const r = http_get("https://api.example.com/data");
// r = { status: 200, body: "<body>", error: "" }
// сетевая ошибка → { status: 0, body: "", error: "..." } — не бросает
const r2 = http_post("https://api.example.com/submit", "payload");
const r3 = http_post(url, body, "application/json");
Все HTTP-методы с одинаковой сигнатурой
(url, body [, content_type [, timeout [, headers]]]):
const r = http_put("https://api.example.com/item/1", `{"title":"x"}`, "application/json");
const r = http_patch("https://api.example.com/item/1", `{"title":"y"}`);
const r = http_delete("https://api.example.com/item/1", "");
Или универсальная форма http(method, url, …):
const r = http("PATCH", url, `{"status":"closed"}`, "application/json");
Таймаут по умолчанию 30с; перекрывается доп. аргументом:
const r = http_get(url, 5); // таймаут 5с
const r = http_post(url, body, "application/json", 5);
Свои заголовки (объект последним аргументом — например Referer):
const token = env_var("MY_API_TOKEN"); // "" если не задан (пишет предупреждение)
const r = http_get(url, 5, { Authorization: `Bearer ${token}` });
LLM
const a = llm("Что такое фотосинтез?"); // только user
const a = llm("Ты — краткий ассистент.", "Что такое фотосинтез?"); // system + user
const a = llm({
user: "Вопрос",
system: "Инструкция", // опционально
max_tokens: 500, // опционально (по умолчанию 1024, максимум 4096)
temperature: 0.3, // опционально
});
- Инференс идёт через провайдер ядра; токены списываются на вызывающий процесс (и его бюджетную группу).
- Уважает лимит конкурентности ядра.
- Бюджет исчерпан → ошибка
caller budget exhausted. - LLM не подключён → вызов бросает; оборачивай в
try ... catch.
Структурированный вывод — llm_json({ ... output_schema }) возвращает
распарсенный JSON-объект. Ядро ретраит инференс (до 3 попыток), пока ответ не
провалидируется против output_schema, подмешивая текст ошибки в следующую
попытку:
const r = llm_json({
user: "Извлеки имя и цену из текста",
output_schema: {
type: "object",
properties: { name: { type: "string" }, price: { type: "number" } },
required: ["name", "price"],
},
});
// r = { name: "...", price: 123 }
Если после всех попыток ответ не валиден — бросок
llm: structured output did not validate....
Cron
Скрипты могут планировать запуски агентов (контракт общий для JS и Rhai). Эти
задания — динамические: персистятся в logs/cron.db и переживают рестарт:
const r = schedule_cron({
name: "watch-price",
template: "hello-agent",
interval_secs: 3600, // или schedule: "0 0 9 * * *", или oneshot_at
prompt: "Проверь цену и сообщи.",
notify: [{ type: "log" }],
});
// r = { name: "watch-price", ok: true }
const gone = cancel_cron("watch-price"); // -> true/false
const jobs = list_cron(); // -> массив статусов заданий
Полный справочник полей, sink'и и мониторинг — в 11-cron-jobs. Без
подключённого cron-сервиса (например hook_tool без сервера) вызовы бросают.
State store
Скрипты могут читать/писать scoped state store (scope резолвится из
вызывающего, id не нужен): state_get(scope, key), state_set(scope, key, value),
state_delete(scope, key), state_keys(scope), state_push, state_range,
state_sadd, state_smembers, state_srem. Семантика операций — как у
state_tool. Требует подключённого
logging.state_db_path.
DuckDB (SQL-базы)
Скрипты могут читать/писать DuckDB-базы через три помощника. Соединения к одному
файлу пулятся процесс-глобально (один писатель на файл), временны́е типы
(TIMESTAMP/DATE/TIME) возвращаются строками ISO 8601:
db_execute("data/dbs/{user}.db",
"CREATE TABLE IF NOT EXISTS log (id BIGINT, text TEXT)");
db_execute("data/dbs/{user}.db", "INSERT INTO log VALUES (1, 'hi')");
const q = db_query("data/dbs/{user}.db", "SELECT * FROM log", 100);
// q = { columns: [...], rows: [...], count: N, has_more: bool }
const s = db_schema("data/dbs/{user}.db"); // таблицы + колонки текстом
const s = db_schema("data/dbs/{user}.db", "log"); // одна таблица
| Функция | Описание |
|---|---|
db_query(path, sql [, limit]) |
SELECT-запрос → { columns, rows, count, has_more } (лимит по умолч. 100) |
db_execute(path, sql) |
DDL/DML → число затронутых строк (бросает на ошибке) |
db_schema(path [, table]) |
интроспекция таблиц/колонок текстом |
path поддерживает плейсхолдеры {user}, {session}, {template}/{agent},
резолвящиеся из контекста вызывающего (как у
duckdb_tool) — так скрипт получает «свою»
базу на пользователя, не видя чужих id.
Shell (CLI)
Скрипты могут запускать команду через оболочку платформы (cmd /C на Windows,
sh -c в остальных). Вызов никогда не бросает — ошибки возвращаются в карте:
const r = shell("git log --oneline -n 5");
// r = { ok: true, exit_code: 0, stdout: "...", stderr: "" }
// таймаут / несуществующая команда → { ok: false, exit_code: -1, stderr: "..." }
const r2 = shell("sleep 60", 5); // жёсткий таймаут 5с (по умолч. 30с)
shell(...) идёт через оболочку (cmd /C / sh -c), поэтому двойные кавычки и
%VAR% (на Windows) интерпретируются оболочкой. Для путей с пробелами, кавычек
внутри аргументов и % в git --pretty=format:... используй exec(...) — он
запускает процесс напрямую, без оболочки, и аргументы передаются дословно:
const r = exec("git", ["-C", ".", "log", "--pretty=format:%H%x1f%an", "-n", "5"]);
const r2 = exec("git", ["log", "--author=Alice Bob"], 30); // третий аргумент — таймаут
Файлы
fs_read(path) читает UTF-8 текстовый файл, fs_write(path, content) пишет
его, fs_list(path) возвращает список записей каталога (каталоги — с / в
конце). Все три бросают на ошибке:
fs_write("report.csv", "a,b\n1,2\n"); // относительный путь → в scratch сессии
const cfg = fs_read("data/git/repos.json"); // чтение — по путям сервера
const entries = fs_list(caller().temp_dir); // ["report.csv", "sub/", ...]
Запись ограничена scratch-каталогом сессии (caller().temp_dir):
- относительный путь (
"report.csv","sub/report.csv") резолвится отtemp_dir; - абсолютный путь допускается только внутри
temp_dir; ..не может вывести за пределыtemp_dir; символические ссылки наружу тоже отклоняются. Иначе — ошибкаfs_write: path ... outside the session temp dir.- Без temp-базы (скрипт вне сессии)
fs_writeбросаетno temp dir ....
Чтение (fs_read/fs_list) остаётся read-only и не ограничено — им можно
читать серверные данные (data/..., конфиги).
Бинарные файлы — fs_read_bytes(path) возвращает содержимое как base64,
fs_write_bytes(path, b64) пишет байты (base64) обратно. Запись так же
ограничена scratch-каталогом сессии:
const b64 = fs_read_bytes("photo.png"); // base64
fs_write_bytes("copy.png", b64); // → temp_dir/copy.png
Контекст вызывающего (caller)
caller() возвращает идентичность процесса, от имени которого выполняется
скрипт (асимметрия с хуками устранена — script_tool тоже видит «кто я»):
const me = caller();
// { pid, session_id, user_id, template, temp_dir } — null, если неизвестно
fs_write(`${me.temp_dir}/notes.txt`, `user=${me.user_id}`);
Вызов других инструментов (call_tool)
Скрипт может вызвать любой зарегистрированный инструмент по имени — ядро диспетчеризует его точно так же, как вызов от LLM (проверка доступа, полный контекст, защита от паники, усечение результата, учёт бюджета и запись в историю результатов):
const r = call_tool("make_docx", { name: "report.docx", content: "# Отчёт" });
// r = { content: "...", success: true, data: ..., visual: ... }
const filled = call_tool("fill_contract", { name: "dogovor.docx", data: { client: "ООО Ромашка" } });
-
Результат — карта
{ content, success, data, visual }(поляdata/visual—null, если тул их не вернул). -
visualпередаётся как есть: скрипт может переслать файл пользователю, вернув его в своёмvisual(например блокfileотoffice_tool):function run(args) { const r = call_tool("make_docx", { name: "report.docx", content: "# Отчёт" }); return { content: "готово", success: r.success, visual: r.visual }; } -
Если ядро не передало сервис вызова тулов (например
hook_toolбез--live), вызов бросаетcall_tool: no tool-call service attached.... -
Рекурсивный вызов того же
script_toolсамим собой ведёт к рекурсии — ограниченmax_operations; не делай этого.
Список доступных инструментов (list_tools)
list_tools() возвращает схемы инструментов, доступных вызывающему процессу
(тот же набор, что видит LLM при function calling):
const tools = list_tools();
// [{ name: "make_docx", description: "...", parameters: { ... } }, ...]
Результат прошлого вызова тула (get_tool_result)
get_tool_result(tool [, index]) читает результат тула, уже выполненного в
этой сессии (без повторного запуска) — композиция без лишних вызовов:
const r = get_tool_result("make_docx"); // последний результат
const prev = get_tool_result("make_docx", 1); // предпоследний
// { content, success, data, visual } — как у call_tool
index— 0-based от последнего вызова (0по умолчанию).- Без результата на индексе — бросок
get_tool_result: no result .... - Читает историю, которую ядро ведёт для композиции (
ToolContext::tool_results); без истории —no tool-result history attached....
Запуск субагента (run_subagent)
Скрипт может делегировать задачу дочернему агенту (спавнится как ребёнок вызывающего, тратит его бюджетную группу, one-shot — чистится после):
const r = run_subagent({
task: "Проанализируй отзыв и дай оценку",
template: "review-agent", // или instructions: "..."
// name, tools, output_schema — опционально
});
// r = { pid, answer, data } — data заполнен, если задан output_schema
Без подключённого сервиса субагентов — бросок run_subagent: no subagent service....
Транскрипт сессии (memory_*)
Скрипт может читать собственный транскрипт сессии (то, что уже сохранилось в message store — сообщения пользователя/агента, tool-call, хуки, warning, error):
const recent = memory_history(20); // последние 20 сообщений, по порядку
const hits = memory_search("поступление", 5); // поиск по словам, по релевантности
const n = memory_count();
// каждая запись: { id, role, kind, content, meta, created_at, score }
- Скоуп —
session:<session_id>вызывающего (резолвится ядром, id не нужен). - Без session id → бросок
memory: no session id...; без message store →memory: no message store.... - Это read-only: скрипт не пишет в транскрипт (пишет только ядро/хуки).
Прочее
| Функция | Описание |
|---|---|
now() |
текущее UTC-время (строка RFC 3339) |
sleep_ms(ms) |
блокирующая пауза (ограничена 60с) — примитив задержки/backoff |
env_var(name) |
переменная окружения ("" если нет; пишет предупреждение) |
print(...), debug(...) |
лог в сервер |
urlencode(s) / url_encode(s) |
percent-encoding UTF-8 |
urldecode(s) / url_decode(s) |
percent-декодирование |
base64_encode(s) / base64_decode(s) |
base64 UTF-8 (decode бросает на невалидном входе) |
ord(c), chr(i) |
символ ↔ кодпоинт |
matches(s, regex), replace_re(s, regex, repl) |
regex-матч / замена |
extract_script_json(html, contains) |
первый JSON-блоб <script> (напр. __NEXT_DATA__) → объект, или null |
json_get(data, pointer) |
разрешить JSON-указатель (/a/b/0, экраны ~0/~1) → значение, или null |
Для JSON в JS используй нативные JSON.parse / JSON.stringify.
7.4 Лимиты и гарантии
- Нет прямого доступа к ядру (
spawn,inject, …) — только HTTP/LLM/ state/DuckDB/shell/exec/fs_read/fs_write/fs_list/fs_read_bytes/fs_write_bytes-помощники,caller(), вызов других инструментов черезcall_tool(...)/list_tools()/get_tool_result(...), чтение транскрипта черезmemory_*и возвращаемые значения. max_operationsобрывает runaway-циклы (while(true){}даёт ошибку, а не зависание).shell(...)дополнительно ограничен жёстким таймаутом.- Состояние не сохраняется между вызовами — каждый вызов получает свежий
скоуп. Для персистентности используй
state_*/db_*-помощники, HTTP/внешние сервисы или встроенные хуки (faq_cache,memory). - Скрипты синхронны:
llm(...)блокирует рабочий поток на время инференса. Нормально для коротких вызовов; избегай десятков последовательных LLM-вызовов в одном скрипте.
7.5 Rhai — альтернативный язык
Для маленьких скриптов можно использовать Rhai (.rhai). Контракт идентичен,
отличается синтаксис:
fn run(args) {
let text = args.text ?? "";
text.trim(); // trim() меняет на месте и возвращает ()
if text.len == 0 { // длина — свойство `.len`
return #{ content: "текст пуст", success: false, error: "empty text" };
}
let words = text.split(" ").filter(|w| w.len > 0).len();
#{ content: `Слов: ${words}`, success: true }
}
Отличия от JS:
- Объекты —
#{ key: value }(не{ … });()— это null. - Длина строки/массива — свойство
.len(не.length). - Строковые методы (
trim,split, …) меняют строку на месте и возвращают(): пишиs.trim();и читайs, а неlet t = s.trim();. - JSON —
parse_json/to_json(вместоJSON.parse/JSON.stringify). max_operationsв Rhai — точный счётчик операций (в JS — грубый лимит инструкций QuickJS).
7.6 Отладка
hook_tool config/scripts/hooks/audit.yaml user "Привет, мир"
hook_tool config/scripts/hooks/moderation.yaml user "Привет, мир" --live
Скриптовые инструменты проверяются тестами в стиле site_tool или в живом чате
с агентом, к которому прикреплён инструмент.