脚本(JavaScript 和 Rhai)
🔵 工具和钩子可以编写为脚本,由内置引擎执行并从 YAML 加载——无需重新构建。两个引擎,根据文件扩展名选择:
- JavaScript(
.js、.mjs、.cjs)——通过 QuickJS 实现的真正 JS。推荐选择:完整的语言和标准库(JSON、Math、String、正则、模板字面量、Array等),熟悉的语法。 - Rhai(
.rhai)——用于非常小脚本的紧凑嵌入式语言。参见 §7.5。
两个引擎提供相同的契约和相同的辅助函数(参见 §7.3)。.js 上的 script: 路径在 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, }, }; }table块的continuation.args包含惰性分页(参见通用字段 和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_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, // опционально
});
- 推理通过核心的提供者进行;令牌计入调用进程(及其预算组)。
- 遵守核心的并发限制。
- 预算耗尽 → 错误
caller budget exhausted。 - LLM 未连接 → 调用抛出;请用
try ... catch包裹。
安全性。 从脚本调用的 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-нормализованные векторы
- 需要在
server.yaml中设置security.onnx并使用带onnx特性的服务器构建;否则调用会抛出。 - 模型在首次访问时下载(策略
download);原始数据不会离开环境。 ner对于长文本会自动切分为窗口并拼接偏移量。
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):
- 相对路径(
"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)保持只读且不受限制——可以用它
读取服务器数据(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");
-
结果是 map
{ content, success, data, visual }(字段data/visual——如果工具没有返回它们,则为null)。 -
visual按原样传递:脚本可以把文件转发给用户, 将其返回到自己的visual中(例如来自office_tool的file块):function run(args) { const r = call_tool("make_docx", { name: "report.docx", content: "# Отчёт" }); return { content: "готово", success: r.success, visual: r.visual }; } -
如果内核没有传入工具调用服务(例如没有
--live的agent-os hook),调用会抛出call_tool: no tool-call service attached...。 -
同一个
script_tool递归调用自身会导致递归—— 受max_operations限制;不要这样做。
可用工具列表(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
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...。
进程间通信(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); // убрать завершённый процесс из таблицы
send/recv/wait会阻塞代理的推进——当对话方 在另一个进程/会话中工作时使用,或者通过带 超时的try_recv/recv轮询。- 没有 IPC 服务——抛出
ipc service not attached to this script call。
会话转录(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...。 - 这是只读的:脚本不会写入转录(只有内核/钩子会写入)。
其他
| 函数 | 描述 |
|---|---|
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 限制与保证
- 没有对内核的直接访问(
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会中断失控循环(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)。 - Rhai 中的
max_operations是精确的操作计数器(在 JS 中是 QuickJS 指令的粗略 限制)。
7.6 调试
agent-os hook config/scripts/hooks/audit.yaml user "Привет, мир"
agent-os hook config/scripts/hooks/moderation.yaml user "Привет, мир" --live
脚本工具通过 agent-os tool 风格的测试,或在附有该工具的代理的实时聊天中
进行验证。