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
44 changes: 26 additions & 18 deletions crates/client/src/agent_os.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ use agentos_sidecar_client::wire;
use agentos_vm_config as vm_config;

use crate::config::{
AgentOsConfig, AgentOsLimits, HostFunction, HostFunctions, MountConfig, RootFilesystemConfig,
resolve_host_functions, AgentOsConfig, AgentOsLimits, MountConfig, ResolvedHostFunction,
ResolvedHostFunctions, RootFilesystemConfig,
RootFilesystemKind, RootFilesystemMode as ConfigRootFilesystemMode, RootLowerInput,
SidecarJsBridgeCall, SidecarJsBridgeCallback, TimerScheduleDriver,
};
Expand Down Expand Up @@ -211,6 +212,8 @@ pub(crate) struct AgentOsInner {

// Config / lifecycle.
pub(crate) config: Arc<AgentOsConfig>,
/// `config.host_functions` resolved to command names once, at create.
pub(crate) host_functions: Vec<ResolvedHostFunctions>,
pub(crate) sidecar: Arc<AgentOsSidecar>,
pub(crate) sidecar_lease: parking_lot::Mutex<Option<AgentOsSidecarVmLease>>,
pub(crate) dynamic_mounts: parking_lot::Mutex<Vec<wire::MountDescriptor>>,
Expand Down Expand Up @@ -487,9 +490,11 @@ impl AgentOs {
// 6b. Register host-function collections (if any): forward each definition via `register_host_callbacks`,
// record the host execute callbacks in the per-VM registry, and install the shared
// host-callback that routes guest host_function calls back to the host by VM.
if !config.host_functions.is_empty() {
let mut host_function_map: HashMap<String, HostFunction> = HashMap::new();
for collection in &config.host_functions {
let resolved_host_functions =
resolve_host_functions(&config.host_functions).map_err(ClientError::InvalidConfig)?;
if !resolved_host_functions.is_empty() {
let mut host_function_map: HashMap<String, ResolvedHostFunction> = HashMap::new();
for collection in &resolved_host_functions {
let mut host_functions = HashMap::new();
for host_function in &collection.functions {
host_functions.insert(
Expand All @@ -515,7 +520,7 @@ impl AgentOs {
wire::RequestPayload::RegisterHostCallbacksRequest(
wire::RegisterHostCallbacksRequest {
name: collection.name.clone(),
description: collection.description.clone(),
description: String::new(),
command_aliases: vec![format!("agentos-{}", collection.name)],
registry_command_aliases: vec![String::from("agentos")],
callbacks: host_functions,
Expand Down Expand Up @@ -580,7 +585,7 @@ impl AgentOs {
let _ = vm_host_functions().insert(
vm_id.clone(),
Arc::new(VmHostFunctionRegistry {
host_functions: config.host_functions.clone(),
host_functions: resolved_host_functions.clone(),
host_function_map,
}),
);
Expand Down Expand Up @@ -626,6 +631,7 @@ impl AgentOs {
durable_agent_exit_tx: broadcast::channel(64).0,
cron,
config,
host_functions: resolved_host_functions,
sidecar,
sidecar_lease: parking_lot::Mutex::new(Some(lease)),
dynamic_mounts: parking_lot::Mutex::new(configured_mounts),
Expand Down Expand Up @@ -874,6 +880,10 @@ impl AgentOs {
&self.inner.config
}

pub(crate) fn host_functions(&self) -> &[ResolvedHostFunctions] {
&self.inner.host_functions
}

pub(crate) fn cron(&self) -> &Arc<CronManager> {
&self.inner.cron
}
Expand Down Expand Up @@ -1371,8 +1381,8 @@ static VM_HOST_FUNCTIONS: OnceCell<SccHashMap<String, Arc<VmHostFunctionRegistry

#[derive(Clone)]
struct VmHostFunctionRegistry {
host_functions: Vec<HostFunctions>,
host_function_map: HashMap<String, HostFunction>,
host_functions: Vec<ResolvedHostFunctions>,
host_function_map: HashMap<String, ResolvedHostFunction>,
}

fn vm_host_functions() -> &'static SccHashMap<String, Arc<VmHostFunctionRegistry>> {
Expand Down Expand Up @@ -2493,7 +2503,7 @@ async fn handle_agentos_host_function_command(
ownership: &wire::OwnershipScope,
registry: &VmHostFunctionRegistry,
command: &HostCommandCallbackInput,
collection: &HostFunctions,
collection: &ResolvedHostFunctions,
) -> Result<Value, String> {
let Some(host_function_name) = command.args.first() else {
return describe_host_functions_payload(&registry.host_functions, &collection.name);
Expand All @@ -2518,7 +2528,7 @@ async fn handle_agentos_host_function_command(
async fn invoke_host_function(
ownership: &wire::OwnershipScope,
registry: &VmHostFunctionRegistry,
collection: &HostFunctions,
collection: &ResolvedHostFunctions,
host_function_name: &str,
args: &[String],
cwd: &str,
Expand Down Expand Up @@ -2552,7 +2562,7 @@ async fn invoke_host_function(

async fn parse_host_function_input(
ownership: &wire::OwnershipScope,
host_function: &HostFunction,
host_function: &ResolvedHostFunction,
args: &[String],
cwd: &str,
) -> Result<Value, String> {
Expand Down Expand Up @@ -3128,7 +3138,7 @@ fn compact_json(value: &Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| String::from("<invalid json>"))
}

fn list_host_functions_payload(host_functions: &[HostFunctions]) -> Value {
fn list_host_functions_payload(host_functions: &[ResolvedHostFunctions]) -> Value {
Value::Object(Map::from_iter([(
String::from("hostFunctions"),
Value::Array(
Expand All @@ -3137,7 +3147,6 @@ fn list_host_functions_payload(host_functions: &[HostFunctions]) -> Value {
.map(|collection| {
json_object([
("name", Value::String(collection.name.clone())),
("description", Value::String(collection.description.clone())),
(
"functions",
Value::Array(
Expand All @@ -3156,7 +3165,7 @@ fn list_host_functions_payload(host_functions: &[HostFunctions]) -> Value {
}

fn describe_host_functions_payload(
host_functions: &[HostFunctions],
host_functions: &[ResolvedHostFunctions],
collection_name: &str,
) -> Result<Value, String> {
let Some(collection) = host_functions
Expand All @@ -3170,7 +3179,6 @@ fn describe_host_functions_payload(
};
Ok(json_object([
("name", Value::String(collection.name.clone())),
("description", Value::String(collection.description.clone())),
(
"functions",
Value::Object(Map::from_iter(collection.functions.iter().map(
Expand All @@ -3197,7 +3205,7 @@ fn describe_host_functions_payload(
}

fn describe_host_function_payload(
collection: &HostFunctions,
collection: &ResolvedHostFunctions,
host_function_name: &str,
) -> Result<Value, String> {
let Some(host_function) = collection
Expand Down Expand Up @@ -3282,15 +3290,15 @@ fn describe_host_function_flag_type(schema: &Value) -> String {
}
}

fn host_functions_names(host_functions: &[HostFunctions]) -> String {
fn host_functions_names(host_functions: &[ResolvedHostFunctions]) -> String {
host_functions
.iter()
.map(|collection| collection.name.clone())
.collect::<Vec<_>>()
.join(", ")
}

fn host_function_names(collection: &HostFunctions) -> String {
fn host_function_names(collection: &ResolvedHostFunctions) -> String {
collection
.functions
.iter()
Expand Down
143 changes: 132 additions & 11 deletions crates/client/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
//! only and become `Arc<dyn ...>` trait objects; they cannot cross the wire and are gated exactly as
//! the actor layer gates them.

use std::collections::BTreeMap;
use std::sync::Arc;

use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -48,8 +49,8 @@ pub struct AgentOsConfig {
pub additional_instructions: Option<String>,
/// Schedule driver used by the cron manager. Default: [`TimerScheduleDriver`].
pub schedule_driver: Option<Arc<dyn ScheduleDriver>>,
/// HostFunction collections to register.
pub host_functions: Vec<HostFunctions>,
/// Host function collections to register, keyed by collection name.
pub host_functions: HostFunctionCollections,
/// Rust-only sidecar callback handler for `js_bridge`-style plugin requests.
pub sidecar_js_bridge_callback: Option<SidecarJsBridgeCallback>,
/// Permission policy. Default: allow-all.
Expand Down Expand Up @@ -126,7 +127,7 @@ impl AgentOsConfigBuilder {
self
}

pub fn host_functions(mut self, host_functions: Vec<HostFunctions>) -> Self {
pub fn host_functions(mut self, host_functions: HostFunctionCollections) -> Self {
self.config.host_functions = host_functions;
self
}
Expand Down Expand Up @@ -232,26 +233,146 @@ pub type SidecarJsBridgeCallback = Arc<
+ Sync,
>;

/// A single host function within a [`HostFunctions`].
/// A single host function. The key it is registered under names it, and the
/// input schema's `description` documents it for the agent.
#[derive(Clone)]
pub struct HostFunction {
pub name: String,
pub description: String,
/// JSON Schema for the host function input (forwarded to the sidecar `register_host_callbacks` definition).
pub input_schema: serde_json::Value,
pub timeout_ms: Option<u64>,
/// Host-side implementation, invoked when the guest calls `<collection>:<function>`.
pub execute: HostFunctionCallback,
}

/// A registered host function collection (in-process; implementations stay host-side). Functions are exposed to the
/// guest as `<collection>:<function>` and dispatched back to [`HostFunction::execute`] via the sidecar
/// host-callback channel.
/// One collection of host functions, keyed by function name. Each key becomes a
/// subcommand of the collection's CLI binary and a method on its guest global.
pub type HostFunctionCollection = BTreeMap<String, HostFunction>;

/// Host function collections, keyed by collection name (in-process;
/// implementations stay host-side). Each key becomes the CLI binary
/// `agentos-{name}` and a frozen guest global; functions are exposed to the
/// guest as `<collection>:<function>` and dispatched back to
/// [`HostFunction::execute`] via the sidecar host-callback channel.
pub type HostFunctionCollections = BTreeMap<String, HostFunctionCollection>;

/// A host function resolved to the names the VM uses.
#[derive(Clone)]
pub struct HostFunctions {
pub struct ResolvedHostFunction {
/// Kebab-case command name. Becomes the CLI subcommand.
pub name: String,
/// Taken from the input schema's `description`.
pub description: String,
pub functions: Vec<HostFunction>,
pub input_schema: serde_json::Value,
pub timeout_ms: Option<u64>,
pub execute: HostFunctionCallback,
}

/// A collection resolved to the names the VM uses. Keys arrive as identifiers
/// and are converted once here, so the rest of the client and the sidecar only
/// ever see kebab-case command names.
#[derive(Clone)]
pub struct ResolvedHostFunctions {
/// Kebab-case collection name. Becomes the CLI suffix: `agentos-{name}`.
pub name: String,
pub functions: Vec<ResolvedHostFunction>,
}

/// Convert a registration key to its command name. `listOrders` and
/// `list-orders` both become `list-orders`, so the guest sees one spelling
/// whichever the caller wrote. Mirrors `hostFunctionCommandName` in the
/// TypeScript client.
pub fn host_function_command_name(key: &str) -> String {
let mut out = String::with_capacity(key.len() + 4);
let chars: Vec<char> = key.chars().collect();
for (index, character) in chars.iter().enumerate() {
if character.is_ascii_uppercase() && index > 0 {
let previous = chars[index - 1];
let next_is_lower = chars
.get(index + 1)
.is_some_and(|next| next.is_ascii_lowercase());
if previous.is_ascii_lowercase()
|| previous.is_ascii_digit()
|| (previous.is_ascii_uppercase() && next_is_lower)
{
out.push('-');
}
}
out.extend(character.to_lowercase());
}
out
}

fn is_command_name(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with('-')
&& !name.ends_with('-')
&& !name.contains("--")
&& name
.chars()
.all(|character| character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-')
}

fn to_command_name(kind: &str, key: &str) -> Result<String, String> {
let name = host_function_command_name(key);
if is_command_name(&name) {
Ok(name)
} else {
Err(format!(
"{kind} name \"{key}\" must be alphanumeric, written in camelCase or with single hyphen separators"
))
}
}

/// The description the agent sees, taken from the input schema's `description`.
fn schema_description(input_schema: &serde_json::Value) -> String {
input_schema
.get("description")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string()
}

/// Resolve the caller's collections into the shape the client and sidecar use.
/// Fails on a key that cannot become a command name, and on two keys that
/// collide once converted.
pub fn resolve_host_functions(
collections: &HostFunctionCollections,
) -> Result<Vec<ResolvedHostFunctions>, String> {
let mut resolved = Vec::with_capacity(collections.len());
let mut seen_collections: BTreeMap<String, String> = BTreeMap::new();

for (collection_key, collection) in collections {
let name = to_command_name("Host function collection", collection_key)?;
if let Some(collided) = seen_collections.get(&name) {
return Err(format!(
"Host function collections \"{collided}\" and \"{collection_key}\" both resolve to the command name \"{name}\""
));
}
seen_collections.insert(name.clone(), collection_key.clone());

let mut functions = Vec::with_capacity(collection.len());
let mut seen_functions: BTreeMap<String, String> = BTreeMap::new();
for (function_key, definition) in collection {
let function_name = to_command_name("Host function", function_key)?;
if let Some(collided) = seen_functions.get(&function_name) {
return Err(format!(
"Host functions \"{collided}\" and \"{function_key}\" in collection \"{collection_key}\" both resolve to the command name \"{function_name}\""
));
}
seen_functions.insert(function_name.clone(), function_key.clone());
functions.push(ResolvedHostFunction {
name: function_name,
description: schema_description(&definition.input_schema),
input_schema: definition.input_schema.clone(),
timeout_ms: definition.timeout_ms,
execute: definition.execute.clone(),
});
}

resolved.push(ResolvedHostFunctions { name, functions });
}

Ok(resolved)
}

// ---------------------------------------------------------------------------
Expand Down
6 changes: 6 additions & 0 deletions crates/client/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ pub enum ClientError {
#[error("ACP operation [{code}]: {message}")]
AcpOperation { code: String, message: String },

/// A caller-supplied config value was rejected before the VM was created,
/// for example a host function key that cannot become a command name.
#[error("invalid config: {0}")]
InvalidConfig(String),

/// A cron schedule string could not be parsed/validated.
#[error("invalid schedule: {0}")]
InvalidSchedule(String),
Expand Down Expand Up @@ -176,6 +181,7 @@ impl ClientError {
ClientError::PathNotAbsolute(_)
| ClientError::PathNotNormalized(_)
| ClientError::PathReadOnly(_)
| ClientError::InvalidConfig(_)
| ClientError::ProcessNotFound(_)
| ClientError::ShellNotFound(_)
| ClientError::SessionNotFound(_)
Expand Down
2 changes: 1 addition & 1 deletion crates/client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ pub use stream::{ByteStream, Subscription};
pub use config::{
node_modules_mount, AcpLimits, AgentOsConfig, AgentOsConfigBuilder, AgentOsLimits,
AgentOsSidecarConfig, FsPermissionRule, FsPermissions, HostFunction, HostFunctionCallback,
HostFunctionLimits, HostFunctions, HttpLimits, JsRuntimeLimits, MountConfig, MountPlugin,
HostFunctionCollection, HostFunctionCollections, HostFunctionLimits, HttpLimits, JsRuntimeLimits, MountConfig, MountPlugin,
OverlayMountConfig, PackageRef, PatternPermissionRule, PatternPermissions, PermissionMode,
Permissions, PluginLimits, PythonLimits, ResourceLimits, RootFilesystemConfig,
RootFilesystemKind, RootFilesystemMode, RootLowerInput, RulePermissions, ScheduleCallback,
Expand Down
Loading
Loading