Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion crates/execution/src/javascript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,12 @@ pub struct GuestRuntimeConfig {
/// keeps the bridge-only snapshot (unchanged behavior). The runtime caches the
/// snapshot process-wide keyed by sha256(bridge_code + this bundle).
pub snapshot_userland_code: Option<String>,
/// Optional sidecar-owned classic script evaluated in the execution's
/// context before the entrypoint, including before an ES module entrypoint
/// links its static imports. The sidecar uses it to define VM-wide guest
/// globals such as host-function collections. Runner isolates for WASM and
/// Python leave it unset.
pub prelude_script: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -2954,7 +2960,11 @@ impl JavascriptExecutionEngine {
mode: u8::from(use_module_mode) | if retain_context { 2 } else { 0 },
file_path: execution_file_path,
bridge_code: V8RuntimeHost::bridge_code().to_owned(),
post_restore_script: String::new(),
post_restore_script: request
.guest_runtime
.prelude_script
.clone()
.unwrap_or_default(),
userland_code: snapshot_userland_code,
high_resolution_time: request.guest_runtime.high_resolution_time,
user_code,
Expand Down
6 changes: 3 additions & 3 deletions crates/native-sidecar/src/execution/child_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4893,7 +4893,7 @@ where
let execution_result = javascript_engine
.start_execution_with_module_reader_and_runtime(
StartJavascriptExecutionRequest {
guest_runtime: guest_runtime_identity(
guest_runtime: javascript_guest_runtime(
&vm,
Some(u64::from(kernel_pid)),
Some(u64::from(parent_kernel_pid)),
Expand Down Expand Up @@ -5575,7 +5575,7 @@ where
let replacement_result = javascript_engine
.prepare_execution_with_module_reader_and_runtime(
StartJavascriptExecutionRequest {
guest_runtime: guest_runtime_identity(
guest_runtime: javascript_guest_runtime(
vm,
Some(u64::from(kernel_pid)),
Some(u64::from(parent_kernel_pid)),
Expand Down Expand Up @@ -6515,7 +6515,7 @@ where
let execution_result = javascript_engine
.start_execution_with_module_reader_and_runtime(
StartJavascriptExecutionRequest {
guest_runtime: guest_runtime_identity(
guest_runtime: javascript_guest_runtime(
&vm,
Some(u64::from(kernel_pid)),
Some(u64::from(parent_kernel_pid)),
Expand Down
19 changes: 18 additions & 1 deletion crates/native-sidecar/src/execution/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3023,9 +3023,26 @@ pub(super) fn guest_runtime_identity(
.as_ref()
.is_some_and(|cfg| cfg.high_resolution_time.unwrap_or(false)),
snapshot_userland_code: None,
prelude_script: None,
}
}

/// Guest-runtime config for a guest Node process. Adds the VM's host-function
/// globals to the shared identity so every guest Node process, however it was
/// launched, can call registered host functions directly.
pub(super) fn javascript_guest_runtime(
vm: &VmState,
virtual_pid: Option<u64>,
virtual_ppid: Option<u64>,
) -> GuestRuntimeConfig {
let mut config = guest_runtime_identity(vm, virtual_pid, virtual_ppid);
let script = host_function_globals_script(&vm.host_functions);
if !script.is_empty() {
config.prelude_script = Some(script);
}
config
}

/// The guest's virtual home directory, sourced from the VM user profile (the
/// same value carried to the guest as `os.homedir()` via `guest_runtime`). Used
/// by sidecar-internal `~`-path resolution; falls back to `/root` for a
Expand Down Expand Up @@ -5076,7 +5093,7 @@ where
let execution = javascript_engine
.start_execution_with_module_reader_and_runtime(
StartJavascriptExecutionRequest {
guest_runtime: guest_runtime_identity(&vm, None, None),
guest_runtime: javascript_guest_runtime(&vm, None, None),
vm_id: vm_id.clone(),
context_id: context.context_id,
argv: std::iter::once(launch_entrypoint.clone())
Expand Down
2 changes: 1 addition & 1 deletion crates/native-sidecar/src/execution/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ use crate::filesystem::{
service_owned_python_filesystem_rpc_request,
};
use crate::host_functions::{
format_host_function_failure_output, is_host_function_command,
format_host_function_failure_output, host_function_globals_script, is_host_function_command,
normalized_host_function_command_name, resolve_host_function_command,
HostFunctionCommandResolution,
};
Expand Down
110 changes: 110 additions & 0 deletions crates/native-sidecar/src/host_functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,59 @@ fn host_function_registration_error(error: HostFunctionRegistrationError) -> Sid
}
}

/// Convert a kebab-case host function or collection name to the camelCase
/// identifier
/// guest JavaScript uses for it.
fn host_function_identifier(name: &str) -> String {
let mut identifier = String::with_capacity(name.len());
let mut upper_next = false;
for character in name.chars() {
if character == '-' {
upper_next = true;
} else if upper_next {
identifier.extend(character.to_uppercase());
upper_next = false;
} else {
identifier.push(character);
}
}
identifier
}

/// Guest script that defines one frozen global per registered host function
/// collection, so guest JavaScript can `await orders.list({ ... })` instead of
/// spawning the `agentos-orders` command by hand. The sidecar runs it before
/// the entrypoint of every guest Node process in the VM. Each function runs
/// that same command, so the `hostFunction` permission scope, input
/// validation, and timeouts all still apply. A collection whose name is
/// already a global is skipped with a warning on stderr rather than shadowing
/// it. Returns an empty string when the VM has no host functions.
pub(crate) fn host_function_globals_script(
host_functions: &BTreeMap<String, RegisterHostCallbacksRequest>,
) -> String {
const DEFINE_GLOBALS: &str = r#"((groups)=>{const call=(command,name)=>(input={})=>import("node:child_process").then(({execFile})=>new Promise((resolve,reject)=>{execFile(command,[name,"--json",JSON.stringify(input)],{maxBuffer:67108864},(error,stdout,stderr)=>{if(error){reject(new Error(String(stderr||"").trim()||error.message));return;}let reply;try{reply=JSON.parse(String(stdout));}catch(parseError){reject(new Error("host function returned invalid JSON: "+parseError.message));return;}if(!reply||reply.ok!==true){reject(new Error((reply&&reply.error)||"host function failed"));return;}resolve(reply.result);});}));for(const [group,definition] of Object.entries(groups)){if(group in globalThis){console.error("agentos: host function group \""+group+"\" was not defined because globalThis."+group+" already exists");continue;}const functions={};for(const [key,name] of definition.functions)functions[key]=call(definition.command,name);Object.defineProperty(globalThis,group,{value:Object.freeze(functions),enumerable:true});}})"#;

let groups = host_functions
.values()
.filter_map(|collection| {
let command = collection.command_aliases.first()?;
let functions = collection
.callbacks
.keys()
.map(|name| serde_json::json!([host_function_identifier(name), name]))
.collect::<Vec<_>>();
Some((
host_function_identifier(&collection.name),
serde_json::json!({ "command": command, "functions": functions }),
))
})
.collect::<serde_json::Map<_, _>>();
if groups.is_empty() {
return String::new();
}
format!("{DEFINE_GLOBALS}({});", serde_json::Value::Object(groups))
}

enum HostFunctionCommand {
Registry(String),
Collection { collection_name: String },
Expand Down Expand Up @@ -1042,3 +1095,60 @@ mod tests {
);
}
}

#[cfg(test)]
mod host_function_globals_tests {
use super::*;
use crate::protocol::RegisteredHostCallbackDefinition;

fn collection(name: &str, callbacks: &[&str]) -> RegisterHostCallbacksRequest {
RegisterHostCallbacksRequest {
name: name.to_string(),
description: String::new(),
command_aliases: vec![format!("agentos-{name}")],
registry_command_aliases: vec![String::from("agentos")],
callbacks: callbacks
.iter()
.map(|callback| {
(
callback.to_string(),
RegisteredHostCallbackDefinition {
description: String::new(),
input_schema: String::from("{}"),
timeout_ms: None,
examples: Vec::new(),
},
)
})
.collect(),
}
}

#[test]
fn converts_kebab_case_names_to_identifiers() {
assert_eq!(host_function_identifier("orders"), "orders");
assert_eq!(host_function_identifier("order-store"), "orderStore");
assert_eq!(
host_function_identifier("list-open-orders"),
"listOpenOrders"
);
}

#[test]
fn is_empty_without_host_functions() {
assert_eq!(host_function_globals_script(&BTreeMap::new()), "");
}

#[test]
fn defines_one_group_per_collection_on_a_single_line() {
let host_functions = BTreeMap::from([(
String::from("order-store"),
collection("order-store", &["list-orders"]),
)]);
let script = host_function_globals_script(&host_functions);
assert!(!script.contains('\n'), "script must stay on one line");
assert!(script.ends_with(
r#"({"orderStore":{"command":"agentos-order-store","functions":[["listOrders","list-orders"]]}});"#
));
}
}
132 changes: 0 additions & 132 deletions crates/native-sidecar/src/language_execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,59 +115,6 @@ fn inline_inputs_prefix(inputs: Option<String>, python: bool) -> String {
}
}

/// Convert a kebab-case host function or collection name to the camelCase
/// identifier
/// guest JavaScript uses for it.
fn host_function_identifier(name: &str) -> String {
let mut identifier = String::with_capacity(name.len());
let mut upper_next = false;
for character in name.chars() {
if character == '-' {
upper_next = true;
} else if upper_next {
identifier.extend(character.to_uppercase());
upper_next = false;
} else {
identifier.push(character);
}
}
identifier
}

/// Guest prefix that defines one frozen global per registered host function
/// collection, so inline JavaScript can `await orders.list({ ... })` instead of
/// spawning the `agentos-orders` command by hand. Each function runs that same
/// command, so the `hostFunction` permission scope, input validation, and
/// timeouts
/// all still apply. The prefix stays on one line so guest line numbers do not
/// shift. A collection whose name is already a global is skipped with a warning
/// on stderr rather than shadowing it.
fn inline_host_functions_prefix(
host_functions: &BTreeMap<String, RegisterHostCallbacksRequest>,
) -> String {
const DEFINE_GLOBALS: &str = r#"((groups)=>{const call=(command,name)=>(input={})=>import("node:child_process").then(({execFile})=>new Promise((resolve,reject)=>{execFile(command,[name,"--json",JSON.stringify(input)],{maxBuffer:67108864},(error,stdout,stderr)=>{if(error){reject(new Error(String(stderr||"").trim()||error.message));return;}let reply;try{reply=JSON.parse(String(stdout));}catch(parseError){reject(new Error("host function returned invalid JSON: "+parseError.message));return;}if(!reply||reply.ok!==true){reject(new Error((reply&&reply.error)||"host function failed"));return;}resolve(reply.result);});}));for(const [group,definition] of Object.entries(groups)){if(group in globalThis){console.error("agentos: host function group \""+group+"\" was not defined because globalThis."+group+" already exists");continue;}const functions={};for(const [key,name] of definition.functions)functions[key]=call(definition.command,name);Object.defineProperty(globalThis,group,{value:Object.freeze(functions),enumerable:true});}})"#;

let groups = host_functions
.values()
.filter_map(|collection| {
let command = collection.command_aliases.first()?;
let functions = collection
.callbacks
.keys()
.map(|name| serde_json::json!([host_function_identifier(name), name]))
.collect::<Vec<_>>();
Some((
host_function_identifier(&collection.name),
serde_json::json!({ "command": command, "functions": functions }),
))
})
.collect::<serde_json::Map<_, _>>();
if groups.is_empty() {
return String::new();
}
format!("{DEFINE_GLOBALS}({});", serde_json::Value::Object(groups))
}

fn semantic_result_path() -> String {
static NEXT_RESULT_ID: AtomicU64 = AtomicU64::new(1);
let nonce = NEXT_RESULT_ID.fetch_add(1, Ordering::Relaxed);
Expand Down Expand Up @@ -1331,26 +1278,6 @@ where
"retainEvents requires a context or spawned language process",
));
}
// Inline JavaScript and TypeScript run as `node -e <source>`. Give them the
// VM's registered host functions as globals.
if operation.retained_language == Some(RetainedExecutionLanguage::JavaScript)
&& operation.args.first().map(String::as_str) == Some("-e")
{
let prefix = self
.vms
.get(&vm_id)
.map(|vm| inline_host_functions_prefix(&vm.host_functions))
.unwrap_or_default();
if !prefix.is_empty() {
if let Some(source) = operation.args.get_mut(1) {
source.insert_str(0, &prefix);
}
if let Some(source) = operation.retained_source.as_mut() {
source.insert_str(0, &prefix);
}
}
}

if !context_execution {
// Ephemeral calls must not park a language interpreter after the
// operation completes.
Expand Down Expand Up @@ -3107,62 +3034,3 @@ fn execution_limit_error(limit: ExecutionLimit) -> ExecutionErrorData {
details: None,
}
}

#[cfg(test)]
mod inline_host_functions_prefix_tests {
use super::*;

fn collection(name: &str, callbacks: &[&str]) -> RegisterHostCallbacksRequest {
RegisterHostCallbacksRequest {
name: name.to_string(),
description: String::new(),
command_aliases: vec![format!("agentos-{name}")],
registry_command_aliases: vec![String::from("agentos")],
callbacks: callbacks
.iter()
.map(|callback| {
(
callback.to_string(),
RegisteredHostCallbackDefinition {
description: String::new(),
input_schema: String::from("{}"),
timeout_ms: None,
examples: Vec::new(),
},
)
})
.collect(),
}
}

#[test]
fn converts_kebab_case_names_to_identifiers() {
assert_eq!(host_function_identifier("orders"), "orders");
assert_eq!(host_function_identifier("order-store"), "orderStore");
assert_eq!(
host_function_identifier("list-open-orders"),
"listOpenOrders"
);
}

#[test]
fn is_empty_without_host_functions() {
assert_eq!(inline_host_functions_prefix(&BTreeMap::new()), "");
}

#[test]
fn defines_one_group_per_collection_on_a_single_line() {
let host_functions = BTreeMap::from([(
String::from("order-store"),
collection("order-store", &["list-orders"]),
)]);
let prefix = inline_host_functions_prefix(&host_functions);
assert!(
!prefix.contains('\n'),
"prefix must not shift guest line numbers"
);
assert!(prefix.ends_with(
r#"({"orderStore":{"command":"agentos-order-store","functions":[["listOrders","list-orders"]]}});"#
));
}
}
10 changes: 7 additions & 3 deletions docs/content/docs/host-functions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: "Expose trusted host functions to guest programs in agentOS VMs as
skill: true
---

Host functions expose JavaScript functions from your process to code running inside the VM. They run as in-process callbacks, so they are available in the library only, not on hosted VMs. Each function is defined with a Zod input schema and becomes an auto-generated CLI command at `/bin/agentos-{name}`, plus an async function that inline JavaScript can call directly.
Host functions expose JavaScript functions from your process to code running inside the VM. They run as in-process callbacks, so they are available in the library only, not on hosted VMs. Each function is defined with a Zod input schema and becomes an auto-generated CLI command at `/bin/agentos-{name}`, plus an async function that guest JavaScript can call directly.

## Getting started

Expand Down Expand Up @@ -66,9 +66,13 @@ Missing required flag: --city

## Call host functions from JavaScript

Inline JavaScript and TypeScript get each collection as a frozen global, and each
Every guest Node process gets each collection as a frozen global, and each
host function as an async function, so generated code calls your tools like any
other API. Keys written in camelCase become kebab-case commands and camelCase guest
other API. This covers inline `vm.javascript` and `vm.typescript` executions,
scripts started with `vm.process.spawn("node", ...)` or `vm.process.exec`,
`node` run from the guest shell, and child processes of guest Node. The globals
are defined before the entry script and its imports run, so a module can use
them at its top level. Keys written in camelCase become kebab-case commands and camelCase guest
identifiers, so `orderStore.listOrders` is `agentos-order-store list-orders` on
the CLI and `orderStore.listOrders(input)` in guest JavaScript.

Expand Down
Loading
Loading