Skip to content
Draft
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
5 changes: 5 additions & 0 deletions docs/content/docs/host-functions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ Host functions run on the host with full access to the host environment, so do n
Pass the same host-function collections to `AgentOs.create()`. The function contract and
generated VM commands are unchanged.

VM-scoped host-function and filesystem-bridge callbacks use the originating
VM's ownership. Each VM retains its own callback handler on a shared sidecar.
Callbacks for an unknown or removed VM are rejected. A VM's handler is removed
after its teardown hooks finish, so disposing one VM preserves its siblings.

<CodeSnippet file="examples/embedded/host-functions.ts" title="hostFunctions.ts" />

Read more in the [embedded API quickstart](/agentos/docs/quickstart-embedded).
9 changes: 9 additions & 0 deletions docs/content/docs/resource-limits.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ sets the live execution count that triggers a warning, not a rejection.
These fields are optional in both Rust and TypeScript Core; omission keeps the
sidecar default.

The TypeScript runtime-core transport retains at most 4,096 VM callback
handlers per connection. Embedded sidecar users can raise this local bound
with `SidecarSpawnOptions.maxVmRequestHandlers`; direct protocol clients use
`SidecarProtocolClientOptions.maxVmRequestHandlers`. The SDK writes a host
console warning at 80% capacity and rejects a new handler with
`SidecarVmRequestHandlerLimit` when full. Replacing a handler keeps the same
slot, and removing it releases capacity. This transport-local option does not
change the native VM admission or executor policy.

Process and terminal output replay is also sidecar-owned. The defaults retain
up to 1,024 events or 1 MiB for each opted-in process, return at most 256 events
or 768 KiB per read, and retain at most 1,024 replay records per VM. Override
Expand Down
29 changes: 15 additions & 14 deletions packages/core/src/agent-os.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,6 @@ import type {
MountConfigJsonValue,
NativeMountPluginDescriptor,
} from "@rivet-dev/agentos-runtime-core/descriptors";
import * as executionProtocol from "@rivet-dev/agentos-runtime-core/protocol";
import type { LivePackageAcquisitionSource } from "@rivet-dev/agentos-runtime-core/request-payloads";
import { SidecarRejectedError } from "@rivet-dev/agentos-runtime-core/sidecar-errors";
import type {
CreateVmConfig,
VmUserConfig,
} from "@rivet-dev/agentos-runtime-core/vm-config";
import {
type HostFunction,
type HostFunctionCollections,
Expand All @@ -28,6 +21,13 @@ import {
resolveHostFunctions,
} from "@rivet-dev/agentos-runtime-core/host-functions";
import { zodToJsonSchema } from "@rivet-dev/agentos-runtime-core/host-functions-zod";
import * as executionProtocol from "@rivet-dev/agentos-runtime-core/protocol";
import type { LivePackageAcquisitionSource } from "@rivet-dev/agentos-runtime-core/request-payloads";
import { SidecarRejectedError } from "@rivet-dev/agentos-runtime-core/sidecar-errors";
import type {
CreateVmConfig,
VmUserConfig,
} from "@rivet-dev/agentos-runtime-core/vm-config";
import type {
CodeEvaluationResult,
CodeExecutionResult,
Expand All @@ -54,6 +54,12 @@ import type {
TypeScriptFileExecutionOptions,
} from "./language-execution.js";
import { parseAgentOsOptions } from "./options-schema.js";
import {
type ReplayReadOptions,
replayPage,
replayPageLimits,
replayWirePageLimits,
} from "./output-replay.js";
import { buildProcessForest } from "./process-forest.js";
import type {
ConnectTerminalOptions,
Expand All @@ -75,12 +81,6 @@ import {
resolveSandboxOptions,
} from "./sandbox.js";
import { resolvePublishedSidecarBinary } from "./sidecar/binary.js";
import {
replayPage,
replayPageLimits,
replayWirePageLimits,
type ReplayReadOptions,
} from "./output-replay.js";
import { findCargoBinary, resolveCargoBinary } from "./sidecar/cargo.js";

export type {
Expand Down Expand Up @@ -5633,7 +5633,7 @@ export class AgentOs {
},
};
}
});
}, this._sidecarVm.vmId);
}

// ── Cron ────────────────────────────────────────────────────
Expand Down Expand Up @@ -5733,6 +5733,7 @@ export class AgentOs {
result.status === "rejected" ? [result.reason] : [],
),
);
this._sidecarClient.setSidecarRequestHandler(null, this._sidecarVm.vmId);
if (errors.length === 1) throw errors[0];
if (errors.length > 1) {
throw new AggregateError(errors, "AgentOS VM disposal failed");
Expand Down
56 changes: 55 additions & 1 deletion packages/core/tests/host-function-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ async function createVmCapturingHandler(
.mockImplementation(function (
this: NativeSidecarProcessClient,
handler: any,
vmId?: string,
) {
if (handler) {
captured = handler as CapturedHandler;
}
// Still install on the real client so the VM behaves normally.
return original.call(this, handler);
return original.call(this, handler, vmId);
});
try {
const vm = await AgentOs.create(options);
Expand Down Expand Up @@ -410,3 +411,56 @@ describe("host-function collection permissions: raw host_callback RPC path", ()
expect(response.error).toMatch(/invalid registry callback/i);
});
});

describe("host functions on a shared sidecar", () => {
test("keeps each VM's callbacks after a sibling is disposed or has no host functions", async () => {
const sidecar = await AgentOs.createSidecar();
const vms: AgentOs[] = [];
const permissions = {
fs: "allow",
childProcess: "allow",
hostFunction: "allow",
} as const;
const invoke = async (vm: AgentOs, id: string) => {
const result = await runCommand(vm, "agentos-identity", ["who"]);
expect(result.exitCode, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({ ok: true, result: { id } });
};
try {
for (const id of ["a", "b", "c"]) {
vms.push(
await AgentOs.create({
sidecar: { kind: "explicit", handle: sidecar },
defaultSoftware: false,
permissions,
hostFunctions: {
identity: {
who: { inputSchema: z.object({}), execute: () => ({ id }) },
},
},
}),
);
}
await invoke(vms[0], "a");
await invoke(vms[1], "b");
await invoke(vms[2], "c");
await vms[1].dispose();
await invoke(vms[0], "a");
vms.push(
await AgentOs.create({
sidecar: { kind: "explicit", handle: sidecar },
defaultSoftware: false,
permissions,
}),
);
await invoke(vms[0], "a");
await invoke(vms[2], "c");
} finally {
try {
await Promise.all(vms.map((vm) => vm.dispose()));
} finally {
await sidecar.dispose();
}
}
});
});
5 changes: 4 additions & 1 deletion packages/core/tests/leak-agent-os-processes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ function makeAgentOs(): {
} {
const noop = () => {};
const kernelMock = { dispose: async () => {} };
const sidecarClientMock = { onEvent: () => noop };
const sidecarClientMock = {
onEvent: () => noop,
setSidecarRequestHandler: noop,
};

// The constructor is private at the type level but callable at runtime; it
// runs the class field initializers (including `_processes = new Map()`).
Expand Down
22 changes: 12 additions & 10 deletions packages/core/tests/native-sidecar-process.nightly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
realpathSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
Expand All @@ -20,10 +20,10 @@ import { createHostDirBackend } from "../src/host-dir-mount.js";
import {
createKernel,
createNodeRuntime,
NodeFileSystem,
createWasmVmRuntime,
NodeFileSystem,
} from "../src/runtime-compat.js";
import { createInMemoryFileSystem } from "../src/test/runtime.js";
import { serializePermissionsForSidecar } from "../src/sidecar/permissions.js";
import {
NativeSidecarKernelProxy,
NativeSidecarProcessClient,
Expand All @@ -34,7 +34,7 @@ import {
serializeRootFilesystemForSidecar,
toSidecarSignalName,
} from "../src/sidecar/rpc-client.js";
import { serializePermissionsForSidecar } from "../src/sidecar/permissions.js";
import { createInMemoryFileSystem } from "../src/test/runtime.js";
import {
findPackageWithCommand,
packageCommandsDir,
Expand All @@ -44,9 +44,7 @@ const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
const SIDECAR_BINARY = process.env.AGENTOS_SIDECAR_BIN
? resolve(process.env.AGENTOS_SIDECAR_BIN)
: join(REPO_ROOT, "target/debug/agentos-native-sidecar");
const REGISTRY_COMMANDS_DIR = packageCommandsDir(
findPackageWithCommand("sh"),
);
const REGISTRY_COMMANDS_DIR = packageCommandsDir(findPackageWithCommand("sh"));
const SIGNAL_STATE_CONTROL_PREFIX = "__AGENT_OS_SIGNAL_STATE__:";
const ALLOW_ALL_VM_PERMISSIONS = {
fs: "allow",
Expand Down Expand Up @@ -389,7 +387,9 @@ describe("native sidecar process client", () => {
});

test("dispatches BARE sidecar_request frames to the registered handler", async () => {
const fixtureRoot = mkdtempSync(join(tmpdir(), "agentos-native-sidecar-request-"));
const fixtureRoot = mkdtempSync(
join(tmpdir(), "agentos-native-sidecar-request-"),
);
cleanupPaths.push(fixtureRoot);
const capturePath = join(fixtureRoot, "captured-response.json");
const driverPath = join(fixtureRoot, "fake-sidecar.mjs");
Expand Down Expand Up @@ -459,7 +459,7 @@ describe("native sidecar process client", () => {
content: "from-handler",
},
};
});
}, "vm-1");

try {
const captured = await waitFor(
Expand Down Expand Up @@ -496,7 +496,9 @@ describe("native sidecar process client", () => {
});

test("dispose forcibly terminates a sidecar that ignores stdin closure", async () => {
const fixtureRoot = mkdtempSync(join(tmpdir(), "agentos-native-sidecar-dispose-"));
const fixtureRoot = mkdtempSync(
join(tmpdir(), "agentos-native-sidecar-dispose-"),
);
cleanupPaths.push(fixtureRoot);
const driverPath = join(fixtureRoot, "stuck-sidecar.mjs");
writeFileSync(
Expand Down
15 changes: 12 additions & 3 deletions packages/runtime-core/src/native-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface StdioSidecarProtocolClientSpawnOptions {
command?: string;
args?: string[];
eventBufferCapacity?: number;
maxVmRequestHandlers?: number;
gracefulExitMs?: number;
forceExitMs?: number;
disposedErrorMessage?: string;
Expand All @@ -44,7 +45,10 @@ type ResolvedStdioSidecarProtocolClientOptions = Required<
| "payloadCodec"
>
> &
Pick<StdioSidecarProtocolClientSpawnOptions, "silenceTimeoutMs">;
Pick<
StdioSidecarProtocolClientSpawnOptions,
"silenceTimeoutMs" | "maxVmRequestHandlers"
>;

export class StdioSidecarProtocolClient implements SidecarProcessTransport {
readonly child: StdioSidecarProcess["child"];
Expand Down Expand Up @@ -77,6 +81,7 @@ export class StdioSidecarProtocolClient implements SidecarProcessTransport {
this.protocolClient = new SidecarProtocolClient({
...transportOptions,
eventBufferCapacity: options.eventBufferCapacity,
maxVmRequestHandlers: options.maxVmRequestHandlers,
payloadCodec: options.payloadCodec,
silenceTimeoutMs: options.silenceTimeoutMs,
// A silent sidecar is dead or wedged; reap the process so it cannot
Expand Down Expand Up @@ -125,6 +130,7 @@ export class StdioSidecarProtocolClient implements SidecarProcessTransport {
}),
{
silenceTimeoutMs: options.silenceTimeoutMs,
maxVmRequestHandlers: options.maxVmRequestHandlers,
eventBufferCapacity:
options.eventBufferCapacity ?? DEFAULT_SIDECAR_EVENT_BUFFER_CAPACITY,
gracefulExitMs:
Expand All @@ -137,8 +143,11 @@ export class StdioSidecarProtocolClient implements SidecarProcessTransport {
);
}

setSidecarRequestHandler(handler: LiveSidecarRequestHandler | null): void {
this.protocolClient.setSidecarRequestHandler(handler);
setSidecarRequestHandler(
handler: LiveSidecarRequestHandler | null,
vmId?: string,
): void {
this.protocolClient.setSidecarRequestHandler(handler, vmId);
}

onEvent(handler: (event: LiveEventFrame) => void): () => void {
Expand Down
Loading