Нативные плагины (Rust DLL)
Agent OS расширяется не только YAML-конфигами и скриптами (JS/Rhai), но и
нативными плагинами — динамическими библиотеками (.dll / .so / .dylib),
написанными на Rust (или C/C++/Zig/Go) и подгружаемыми в рантайме.
Плагин может контрибутить «kinds» для всех точек расширения:
| Категория | Что плагин предоставляет | Как подключается |
|---|---|---|
| Тулы | kind: dll_tool |
YAML-файл тула |
| Хуки | kind: dll_hook |
YAML-файл хука |
| Синки | type: plugin в notify: |
cron / монитор / канал |
| Источники | type: plugin в source: |
config/monitors.yaml |
| Каналы | kind: в channels: |
config/channels.yaml |
| HTTP-роуты | register_route |
регистрируются при загрузке |
Главная идея: стабильный C ABI
Плагин линкуется только против маленькой крейты-заголовка
agent_os_plugin_api (ноль зависимостей, #![no_std]). Всё остальное — сервисы
ядра, аксессоры значений, DuckDB — он получает в рантайме как таблицы
указателей на функции (AoHostApi) и #[repr(C)]-структуры с фиксированным
лейаутом.
Поэтому:
- исходники ОС не нужны — только заголовок ABI и контракт;
- версия rustc не важна (это C ABI, а не ABI Rust);
- язык может быть любым (C/C++/Zig/Go/cgo/Rust
cdylib).
Минимальный плагин
# Cargo.toml
[package]
name = "my_plugin"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
agent_os_plugin_api = "0.1" # заголовок ABI — и всё
// src/lib.rs
use agent_os_plugin_api::*;
use std::ffi::c_void;
// тул `echo`
unsafe extern "C-unwind" fn echo_create(_cfg: *const AoValue, _host: *const AoHostApi,
out: *mut *mut c_void, _err: *mut AoError) -> i32 {
*out = 1usize as *mut c_void;
error::OK
}
unsafe extern "C-unwind" fn echo_execute(_inst: *mut c_void, args: *const AoValue,
host: *const AoHostApi, out: *mut AoToolResult) -> i32 {
let host = &*host;
let text = (host.value_get)(host, args, AoStr::from_str("text"));
let text = (host.value_str)(host, text).as_str();
let reply = format!("echo: {text}");
let buf = (host.str_alloc)(host, reply.len());
core::ptr::copy_nonoverlapping(reply.as_ptr(), buf, reply.len());
*out = AoToolResult {
success: true,
content: AoStr { ptr: buf, len: reply.len() },
monetary_micros: 0, token_surcharge: 0, duration_ms: 0,
data: AoStr::null(), visual: AoStr::null(),
};
error::OK
}
unsafe extern "C-unwind" fn echo_destroy(_inst: *mut c_void) {}
static ECHO: AoToolVtable = AoToolVtable { create: echo_create, execute: echo_execute, destroy: echo_destroy };
#[no_mangle]
pub extern "C-unwind" fn ao_plugin_init(_host: *const AoHostApi, reg: *const AoRegistrar) -> u32 {
unsafe {
let reg = &*reg;
(reg.register_tool)(reg, AoStr::from_str("echo"),
AoToolSchema {
name: AoStr::from_str("echo"),
description: AoStr::from_str("Echo the text back."),
parameters_json: AoStr::from_str(r#"{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}"#),
ui_json: AoStr::null(),
},
&ECHO);
}
AO_ABI_VERSION
}
Полный рабочий пример — crate agent_os_test_plugin в репозитории (все шесть
категорий). Сборка: cargo build -p agent_os_test_plugin.
Точка входа и регистрация
Плагин экспортирует один символ:
pub extern "C-unwind" fn ao_plugin_init(host: *const AoHostApi, reg: *const AoRegistrar) -> u32
reg (AoRegistrar) — таблица функций регистрации kinds:
reg.register_tool(name, schema, vtable) // тул
reg.register_hook(name, vtable) // хук
reg.register_sink(name, vtable) // синк
reg.register_source(name, vtable) // источник монитора
reg.register_channel(name, vtable) // входящий канал
reg.register_route(method, path, access, vtable) // HTTP-роут
Возвращаемое значение — версия ABI (AO_ABI_VERSION); хост отвергает
несовпадение до вызова. Функции возвращают 0 = error::OK, иначе код ошибки.
Сервисы ядра (AoHostApi)
// память
host.str_alloc(len) -> *mut u8 // аллоцировать возвращаемую строку
host.str_free(s) // (хост сам освобождает возвращённые строки)
host.slice_alloc(len) / host.slice_free // буферы (массивы, body)
// значения (только чтение, borrowed)
host.value_kind(v) / value_get(v, key) / value_index(v, i)
host.value_str(v) / value_bool(v) / value_int(v) / value_float(v) / value_len(v)
// ядро
host.llm(req, out) // один инференс, бюджет — на вызывающий процесс
host.state_get(scope, key, out) // scope = "user" | "session" | "agent"
host.state_set(scope, key, json) // значение — JSON-текст
host.state_delete(scope, key, out) / host.state_keys(scope, out)
// DuckDB (плагино-собственная БД)
host.db_open(name, out) // data/plugins/<name>.db
host.db_exec(db, sql, err) // DDL/DML
host.db_query(db, sql, out, err) // SELECT → rows_*
host.db_close(db)
host.rows_count/cols/col_name/value
// рантайм
host.run_turn(template, session, user, text, out) // полный turn через ядро
host.should_stop(inst) // каналу пора останавливаться
host.sleep_ms(ms) / host.now(out)
host.log(level, msg)
Контракт по памяти
- Строки/буферы, которые возвращает плагин (
content,data,visual,notice,reply,cursor, тело ответа, массивыinject/items): плагин аллоцирует их черезhost.str_alloc/host.slice_allocровно нужной длины; хост освобождает сам. Статичные строковые литералы возвращать нельзя. - Строки, которые возвращает хост (
value_str,llm→out,state_keys,run_turn→out,rows_col_name): borrowed, валидны только в течение вызова — копируй, если сохраняешь. - Пустая строка (
len == 0/ptr == null) = «нет значения».
Паники
Все указатели функций — extern "C-unwind" (ABI-идентично C, но разрешает
unwind). Хост оборачивает каждый вызов в catch_unwind, поэтому паника плагина
не уронит процесс. Всё же лучше ловить паники внутри своих extern-функций и
возвращать код ошибки.
Подключение к ОС
Загрузка DLL
# config/plugins.yaml
data_dir: data/plugins # где живут БД плагинов (db_open)
plugins:
- plugins/echo.dll # загрузить при старте (регистрирует все kinds)
Загрузка идемпотентна по пути: если несколько агентов (или тул/хук YAML-файлов)
ссылаются на одну DLL, она загружается один раз, ao_plugin_init выполняется
один раз, а kinds регистрируются один раз (повторные load возвращают тот же
экземпляр из кэша).
Тул
# config/agents/<name>/tools/echo.yaml
kind: dll_tool
path: plugins/echo.dll # относительный путь от YAML
name: echo # опционально (по умолчанию — имя из плагина)
config: # опционально: доступно в create(config) через value_*
prefix: ">> "
max_len: 120
config: — произвольный YAML/JSON, который хост передаёт в create(config) как
AoValue; плагин читает его аксессорами (value_get / value_str / …).
config может быть null — проверяй config на null (или читай аксессорами:
они возвращают null/пусто на null-указателе).
Хук
# config/agents/<name>/hooks/audit.yaml
kind: dll_hook
path: plugins/echo.dll
hook: audit # имя kind, зарегистрированного плагином
Синк
# в notify: cron-задачи, монитора, воркфлоу или канала
- type: plugin
kind: collect
config: { ... }
Источник монитора
# config/monitors.yaml
monitors:
- name: example
source:
type: plugin
kind: counter
config: { ... }
template: ngu-agent
prompt: "{{items}}"
Канал
# config/channels.yaml
channels:
- kind: echo_channel
config:
template: ngu-agent
token_env: MY_TOKEN
HTTP-роут
Регистрируется плагином при загрузке; access — route_access::PUBLIC
(0), TOKEN (1, bearer-токен inbound.token_env) или ADMIN (2).
Доверие и безопасность
Нативный плагин — это машинный код в процессе сервера, его нельзя
песочничить как скрипты (нет лимита операций). Это тот же уровень доверия, что
и скомпилированные инструменты. Загружайте плагины только из доверенных
источников; держите конфиг plugins.yaml под контролем (в репозитории/деплое).
Версионирование ABI
AO_ABI_VERSION— рукопожатие вao_plugin_init; хост отказывает при несовпадении.- Поля в
#[repr(C)]-структурах только добавляются в конец; никогда не удаляются и не переставляются. - Новые сервисы появляются новыми полями
AoHostApi; старые плагины, собранные против старого заголовка, продолжают работать (они читают только известные им поля).