脚本(JavaScript 和 Rhai)

🔵 工具和钩子可以编写为脚本,由内置引擎执行并从 YAML 加载——无需重新构建。两个引擎,根据文件扩展名选择:

两个引擎提供相同的契约和相同的辅助函数(参见 §7.3)。.js 上的 script: 路径在 QuickJS 上运行;任何其他扩展名在 Rhai 上运行。

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: "короткая причина" };
}

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_assistant_message(ctx, answer) {
    // answer: { role: "assistant", content } — финальный ответ
    // Output-guardrail: переписать/подавить ответ до сохранения.
    return {
        content: "[скрыто]",    // переписать текст на месте
        replace: { role: "assistant", content: "безопасный ответ" }, // заменить целиком
        suppress: false,        // не сохранять ответ
        notice: "ответ отредактирован", // заметка в чат-UI
    };
}

function after_loop(ctx, reason) {
    // reason: "job_done" | "terminated" | "blocked" | "budget_exhausted" | ...
    // возвращаемое значение игнорируется
}

function before_tool_call(ctx, tool, args) {
    // tool: имя инструмента; args: аргументы (JSON)
    return {
        arguments: { ... },                    // заменить аргументы
        deny: "не разрешено",                  // отклонить с причиной (failed-результат)
        result: { content: "из кэша", success: true }, // полностью подменить результат
        notice: "применена политика",          // заметка в чат-UI
    };
}

function after_tool_result(ctx, tool, result) {
    // result: { content, success, data, visual }
    return {
        content: "[скрыто]",                   // заменить текст для LLM
        success: true,
        data: { ... },
        visual: { ... },
        notice: "вырезаны персональные данные",
    };
}

function before_inference(ctx, request) {
    // request: { model, messages, tools, max_tokens, temperature }
    // Можно менять запрос и/или управлять вызовом:
    return { model: "gpt-4o-mini", temperature: 0.2 };
    // { deny: "причина" }  — отказать (ход блокируется)
    // { response: "..." }  — синтетический ответ без провайдера
}

function before_retry(ctx, attempt, error) {
    // Решение о ретрае транзиентной ошибки инференса.
    return { retry: true, delay_ms: 250 };
}

function before_syscall(ctx, syscall) {
    // syscall: сериализованный Syscall (как JSON), в т.ч. Send/Recv.
    return { deny: "межагентный трафик запрещён" }; // или {} для разрешения
}

function on_budget_threshold(ctx, level, spent, max, ratio) {
    // Бюджет слоя пересёк warn_at (или исчерпан): level = session|group|user.
}

function on_token(ctx, token, final_answer) {
    // token: стримимый чанк; final_answer: true — токены ответа
    // Output-only модерация/редакция: заменить или подавить токен для клиента.
    return { replace: "***", suppress: false };
}

function before_context_evict(ctx, evicted, summary) {
    // evicted: массив { role, content }, вытесненных из окна контекста
    // summary: текущее summary (или null) — вернуть кастомную компакцию
    return { summary: "сжатый старый контекст" };
}

function on_spawn(ctx) { }                 // после создания этого процесса
function on_terminate(ctx, exit_code) { }  // при завершении процесса
function on_error(ctx, message) { }        // при ошибке ядра у процесса
function on_session_created(ctx, session_id) { }  // сессия создана
function on_session_closed(ctx, session_id) { }   // сессия закрыта/истекла

脚本错误永远不会破坏代理的运行。

脚本钩子可以看到会话的 scratch 目录:相对 fs_write("x.txt", …) 写入 caller().temp_dir(与 script_tool 相同)。对于通过 agent-os hook 的本地检查,请设置 --session <id>。


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)。选项 content_type / timeout / headers 是可选的,并按类型识别(字符串 / 数字 / 对象),因此它们可以以任意顺序传递:

const token = env_var("MY_API_TOKEN");   // "" если не задан (пишет предупреждение)
const r = http_get(url, 5, { Authorization: `Bearer ${token}` });
const r = http_post(url, body, "application/json", 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,         // опционально
});

安全性。 从脚本调用的 llm(...) 默认通过连接的安全钩子 guard/pseudonymize:替代/编辑发送到提供者,响应为脚本展开回来。为特定调用禁用——llm({ user: "...", safety: false })。核心内部调用(裁判 faq_cache、记忆提取、condense、摘要 compact_results)不经过膜。

结构化输出 —— 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...。

本地 ONNX 模型

脚本(JS 和 Rhai)可以调用核心的本地 ONNX 服务——NER、分类、嵌入:

const ents = ner("Иван Петров, a@b.com");      // [{ kind, start, end, value, score }]
const only = ner(text, ["person", "org"]);      // фильтр по классам

const labels = classify("topic-mini", text);    // [{ label, score }] (softmax/sigmoid)
const top = classify("topic-mini", text, 3);    // top-3

const vecs = embed("mini-emb", ["a", "b"]);     // L2-нормализованные векторы

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 服务(例如没有服务器的 agent-os hook),调用会抛出异常。

State store

脚本可以读取/写入作用域内的 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)

脚本可以通过平台 shell 运行命令(Windows 上为 cmd /C, 其他平台为 sh -c)。调用从不抛出异常——错误会返回到 map 中:

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(...) 会经过 shell(cmd /C / sh -c),因此双引号和 %VAR%(在 Windows 上)会被 shell 解释。对于带空格的路径、参数内部的引号 以及 git --pretty=format:... 中的 %,请使用 exec(...)——它会 直接启动进程,不经过 shell,参数会原样传递:

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):

读取(fs_read/fs_list)保持只读且不受限制——可以用它 读取服务器数据(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() 返回以之身份执行 脚本的进程身份(与 hook 的不对称性已消除——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 调用一样对其进行调度(访问检查、完整 上下文、防 panic 保护、结果截断、预算核算并写入结果 历史):

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: "ООО Ромашка" } });

对于有副作用的工具,可以传入幂等键作为第三个 参数——在 TTL 窗口内用同一个键重复调用会返回 保存的结果,而不执行该工具(参见 05-tools-core 中的 idempotency:):

const paid = call_tool("pay", { amount: 100 }, "invoice-42");

可用工具列表(list_tools)

list_tools() 返回调用方进程可用的工具 schema (与 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

启动子代理(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...。

进程间通信(send / recv / wait / reap)

脚本可以与其他代理进程协调——交换 消息并等待进程完成(当内核连接了 IPC 服务时可用;在普通服务器中——始终可用):

send(child_pid, "собери данные");          // отправить текст другому процессу

let msg = try_recv(my_pid);                // неблокирующий приём: map или null
let msg = recv(my_pid);                    // блокирующий приём
let msg = recv(my_pid, 5000);              // приём с таймаутом (мс): null если пусто
// msg = { from, to, kind, text|value|… }   (kind: text | json | signal | …)

let r = wait(child_pid);                   // дождаться завершения процесса
// r = { pid, exit_code, completion_reason, completion_data }

reap(child_pid);                           // убрать завершённый процесс из таблицы

会话转录(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 }

其他

函数 描述
now() 当前 UTC 时间(RFC 3339 字符串)
sleep_ms(ms) 阻塞式暂停(上限 60 秒)——延迟/退避原语
env_var(name) 环境变量(如果没有则为 "";并写入警告)
print(...)、debug(...) 记录到服务器日志
urlencode(s) / url_encode(s) UTF-8 percent-encoding
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

在 JS 中处理 JSON 请使用原生的 JSON.parse / JSON.stringify。


7.4 限制与保证


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 的区别:


7.6 调试

agent-os hook config/scripts/hooks/audit.yaml user "Привет, мир"
agent-os hook config/scripts/hooks/moderation.yaml user "Привет, мир" --live

脚本工具通过 agent-os tool 风格的测试,或在附有该工具的代理的实时聊天中 进行验证。