diff --git a/examples/agent-os/package.json b/examples/agent-os/package.json index 7823fd392b..0bc4a8ccc1 100644 --- a/examples/agent-os/package.json +++ b/examples/agent-os/package.json @@ -25,7 +25,9 @@ "check-types": "tsc --noEmit" }, "dependencies": { + "@rivet-dev/agent-os-common": "*", "rivetkit": "*", + "sandbox-agent": "*", "zod": "^3.25.0" }, "devDependencies": { diff --git a/examples/agent-os/src/sandbox/client.ts b/examples/agent-os/src/sandbox/client.ts index 84bcee872b..4d36e146d0 100644 --- a/examples/agent-os/src/sandbox/client.ts +++ b/examples/agent-os/src/sandbox/client.ts @@ -1,4 +1,4 @@ -// Sandbox extension: write files and run commands in a Docker sandbox. +// Sandbox mounting: write files and run commands in a Docker sandbox. // // The /sandbox mount projects the sandbox filesystem into the VM. // The sandbox toolkit is accessible via the tools RPC server. diff --git a/examples/agent-os/src/sandbox/server.ts b/examples/agent-os/src/sandbox/server.ts index 5252fd0863..60857bbe6d 100644 --- a/examples/agent-os/src/sandbox/server.ts +++ b/examples/agent-os/src/sandbox/server.ts @@ -1,34 +1,34 @@ -// Sandbox extension: mount a remote Docker sandbox into the VM. +// Sandbox mounting: each actor instance gets its own Docker sandbox. // // Requires Docker running locally. The sandbox-agent package manages the // container lifecycle. The sandbox filesystem is mounted at /sandbox and // the toolkit exposes process management as CLI commands. +// +// Using `createOptions` with `c.getSandbox()` ensures every actor instance +// spawned via `client.vm.getOrCreate(...)` provisions a dedicated sandbox. +// The sandbox ID is persisted internally across sleep/wake so the actor +// reconnects to the same container. The sandbox is auto-destroyed when the +// actor is destroyed. -import { agentOs } from "rivetkit/agent-os"; -import { setup } from "rivetkit"; import common from "@rivet-dev/agent-os-common"; -import { SandboxAgent } from "sandbox-agent"; +import { setup } from "rivetkit"; +import { agentOs } from "rivetkit/agent-os"; import { docker } from "sandbox-agent/docker"; -import { - createSandboxFs, - createSandboxToolkit, -} from "@rivet-dev/agent-os-sandbox"; - -// Start a Docker-backed sandbox. -const sandbox = await SandboxAgent.start({ - sandbox: docker(), -}); const vm = agentOs({ - options: { - software: [common], - mounts: [ - { - path: "/sandbox", - driver: createSandboxFs({ client: sandbox }), - }, - ], - toolKits: [createSandboxToolkit({ client: sandbox })], + createOptions: async (c) => { + const { fs, toolkit } = await c.getSandbox({ provider: docker() }); + + return { + software: [common], + mounts: [ + { + path: "/sandbox", + driver: fs, + }, + ], + toolKits: [toolkit], + }; }, }); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/agent-os.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/agent-os.ts index 87f1bf28e6..7b827976fe 100644 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/agent-os.ts +++ b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/agent-os.ts @@ -1,4 +1,11 @@ -import { agentOs } from "rivetkit/agent-os"; import common from "@rivet-dev/agent-os-common"; +import { agentOs } from "rivetkit/agent-os"; export const agentOsTestActor = agentOs({ options: { software: [common] } }); + +// Same actor using the per-instance createOptions factory path. The factory +// returns identical options to the static fixture above so the driver tests +// can verify both paths produce a working VM. +export const agentOsCreateOptionsTestActor = agentOs({ + createOptions: async () => ({ software: [common] }), +}); diff --git a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry.ts b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry.ts index 12a5e74d49..fc74df811a 100644 --- a/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry.ts +++ b/rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/registry.ts @@ -3,8 +3,6 @@ import { accessControlActor, accessControlNoQueuesActor, } from "./access-control"; -import { agentOsTestActor } from "./agent-os"; - import { inputActor } from "./action-inputs"; import { defaultTimeoutActor, @@ -20,8 +18,8 @@ import { import { dbActorDrizzle } from "./actor-db-drizzle"; import { dbActorRaw } from "./actor-db-raw"; import { onStateChangeActor } from "./actor-onstatechange"; +import { agentOsCreateOptionsTestActor, agentOsTestActor } from "./agent-os"; import { connErrorSerializationActor } from "./conn-error-serialization"; -import { dbPragmaMigrationActor } from "./db-pragma-migration"; import { counterWithParams } from "./conn-params"; import { connStateActor } from "./conn-state"; // Import actors from individual files @@ -33,13 +31,11 @@ import { dbLifecycleFailing, dbLifecycleObserver, } from "./db-lifecycle"; +import { dbPragmaMigrationActor } from "./db-pragma-migration"; import { destroyActor, destroyObserver } from "./destroy"; import { customTimeoutActor, errorHandlingActor } from "./error-handling"; import { fileSystemHibernationCleanupActor } from "./file-system-hibernation-cleanup"; -import { - hibernationActor, - hibernationSleepWindowActor, -} from "./hibernation"; +import { hibernationActor, hibernationSleepWindowActor } from "./hibernation"; import { inlineClientActor } from "./inline-client"; import { kvActor } from "./kv"; import { largePayloadActor, largePayloadConnActor } from "./large-payloads"; @@ -76,41 +72,41 @@ import { sleep, sleepRawWsAddEventListenerClose, sleepRawWsAddEventListenerMessage, + sleepRawWsDelayedSendOnSleep, + sleepRawWsOnClose, + sleepRawWsOnMessage, + sleepRawWsSendOnSleep, sleepWithLongRpc, sleepWithNoSleepOption, sleepWithPreventSleep, sleepWithRawHttp, sleepWithRawWebSocket, - sleepWithWaitUntilMessage, - sleepRawWsOnClose, - sleepRawWsOnMessage, - sleepRawWsSendOnSleep, - sleepRawWsDelayedSendOnSleep, sleepWithWaitUntilInOnWake, + sleepWithWaitUntilMessage, } from "./sleep"; import { - sleepWithDb, - sleepWithSlowScheduledDb, - sleepWithDbConn, - sleepWithDbAction, - sleepWithRawWsCloseDb, - sleepWithRawWsCloseDbListener, - sleepWsMessageExceedsGrace, - sleepWsConcurrentDbExceedsGrace, - sleepWaitUntil, - sleepNestedWaitUntil, sleepEnqueue, - sleepScheduleAfter, + sleepNestedWaitUntil, sleepOnSleepThrows, + sleepScheduleAfter, + sleepWaitUntil, sleepWaitUntilRejects, sleepWaitUntilState, + sleepWithDb, + sleepWithDbAction, + sleepWithDbConn, sleepWithRawWs, + sleepWithRawWsCloseDb, + sleepWithRawWsCloseDbListener, + sleepWithSlowScheduledDb, sleepWsActiveDbExceedsGrace, + sleepWsConcurrentDbExceedsGrace, + sleepWsMessageExceedsGrace, sleepWsRawDbAfterClose, } from "./sleep-db"; import { lifecycleObserver, startStopRaceActor } from "./start-stop-race"; -import { statelessActor } from "./stateless"; import { stateZodCoercionActor } from "./state-zod-coercion"; +import { statelessActor } from "./stateless"; import { driverCtxActor, dynamicVarActor, @@ -131,8 +127,8 @@ import { workflowNestedLoopActor, workflowNestedRaceActor, workflowQueueActor, - workflowRunningStepActor, workflowReplayActor, + workflowRunningStepActor, workflowSleepActor, workflowSpawnChildActor, workflowSpawnParentActor, @@ -304,5 +300,6 @@ export const registry = setup({ stateZodCoercionActor, // From agent-os.ts agentOsTestActor, + agentOsCreateOptionsTestActor, }, }); diff --git a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/index.ts b/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/index.ts index 30bdf37cfa..b1d4b0eb89 100644 --- a/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/index.ts +++ b/rivetkit-typescript/packages/rivetkit/src/agent-os/actor/index.ts @@ -1,5 +1,5 @@ -import { AgentOs, createInMemoryFileSystem } from "@rivet-dev/agent-os-core"; import type { AgentOsOptions, MountConfig } from "@rivet-dev/agent-os-core"; +import { AgentOs, createInMemoryFileSystem } from "@rivet-dev/agent-os-core"; import type { DatabaseProvider } from "@/actor/database"; import { actor, event } from "@/actor/mod"; import type { RawAccess } from "@/db/config"; @@ -42,35 +42,75 @@ async function ensureVm( c: AgentOsActionContext, config: AgentOsActorConfig, ): Promise { + // Fast path: VM already booted. if (c.vars.agentOs) { return c.vars.agentOs; } - const start = Date.now(); + // Guard against concurrent callers. If another action is already booting + // the VM, wait for that same promise instead of creating a duplicate. + if (c.vars.vmBootGuard) { + return c.vars.vmBootGuard; + } - // Build options with in-memory VFS as default working directory mount. - const options = buildVmOptions(config.options); + const bootPromise = (async (): Promise => { + const start = Date.now(); - const agentOs = await AgentOs.create(options); - c.vars.agentOs = agentOs; + // Resolve options from the per-actor-instance factory or the static config. + let resolvedOptions: AgentOsOptions | undefined; + if (config.createOptions) { + c.log.debug({ msg: "agent-os resolving createOptions" }); + try { + resolvedOptions = await config.createOptions(c); + } catch (err) { + throw new Error( + `agentOs: createOptions callback failed: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ); + } + } else { + resolvedOptions = config.options; + } - // Wire cron events to actor events. - agentOs.onCronEvent((cronEvent) => { - c.broadcast("cronEvent", { event: cronEvent }); - }); + if (!resolvedOptions) { + throw new Error( + "agentOs: createOptions callback returned a falsy value. It must return an AgentOsOptions object.", + ); + } - c.broadcast("vmBooted", {}); - c.log.info({ - msg: "agent-os vm booted", - bootDurationMs: Date.now() - start, - }); + // Build options with in-memory VFS as default working directory mount. + const options = buildVmOptions(resolvedOptions); + + const agentOs = await AgentOs.create(options); + c.vars.agentOs = agentOs; + + // Wire cron events to actor events. + agentOs.onCronEvent((cronEvent) => { + c.broadcast("cronEvent", { event: cronEvent }); + }); + + c.broadcast("vmBooted", {}); + c.log.info({ + msg: "agent-os vm booted", + bootDurationMs: Date.now() - start, + }); + + return agentOs; + })(); + + c.vars.vmBootGuard = bootPromise; - return agentOs; + try { + return await bootPromise; + } catch (err) { + // Clear the cached promise on failure so the next caller retries + // instead of reusing a rejected promise. + c.vars.vmBootGuard = null; + throw err; + } } -function buildVmOptions( - userOptions?: AgentOsOptions, -): AgentOsOptions { +function buildVmOptions(userOptions?: AgentOsOptions): AgentOsOptions { const userMounts = userOptions?.mounts ?? []; // Check if the user already provided a mount at /home/user. If so, respect @@ -171,9 +211,12 @@ export function agentOs( sleepGracePeriod: 900_000, actionTimeout: 900_000, }, - createState: async () => ({}), + createState: async () => ({ + sandboxId: null, + }), createVars: () => ({ agentOs: null, + vmBootGuard: null, activeSessionIds: new Set(), activeProcesses: new Set(), activeHooks: new Set>(), @@ -216,9 +259,13 @@ export function agentOs( activeShells: c.vars.activeShells.size, }); - if (c.vars.agentOs) { - await c.vars.agentOs.dispose(); + try { + if (c.vars.agentOs) { + await c.vars.agentOs.dispose(); + } + } finally { c.vars.agentOs = null; + c.vars.vmBootGuard = null; } c.broadcast("vmShutdown", { reason: "sleep" as const }); @@ -231,9 +278,13 @@ export function agentOs( activeShells: c.vars.activeShells.size, }); - if (c.vars.agentOs) { - await c.vars.agentOs.dispose(); + try { + if (c.vars.agentOs) { + await c.vars.agentOs.dispose(); + } + } finally { c.vars.agentOs = null; + c.vars.vmBootGuard = null; } c.broadcast("vmShutdown", { reason: "destroy" as const }); @@ -264,4 +315,4 @@ const processExitToken = event(); const shellDataToken = event(); const cronEventToken = event(); -export { ensureVm, syncPreventSleep, runHook }; +export { ensureVm, runHook, syncPreventSleep }; diff --git a/rivetkit-typescript/packages/rivetkit/src/agent-os/config.ts b/rivetkit-typescript/packages/rivetkit/src/agent-os/config.ts index 2873ab9427..580cde58c8 100644 --- a/rivetkit-typescript/packages/rivetkit/src/agent-os/config.ts +++ b/rivetkit-typescript/packages/rivetkit/src/agent-os/config.ts @@ -3,13 +3,18 @@ import type { JsonRpcNotification, PermissionRequest, } from "@rivet-dev/agent-os-core"; -import type { ActorContext, BeforeConnectContext } from "@/actor/contexts"; import { z } from "zod/v4"; +import type { ActorContext, BeforeConnectContext } from "@/actor/contexts"; +import type { DatabaseProvider } from "@/actor/database"; +import type { RawAccess } from "@/db/config"; import type { AgentOsActorState, AgentOsActorVars } from "./types"; const zFunction = < - T extends (...args: any[]) => any = (...args: unknown[]) => unknown, ->() => z.custom((val) => typeof val === "function"); + T extends (...args: never[]) => unknown = (...args: unknown[]) => unknown, +>() => + z.custom((val) => typeof val === "function", { + message: "Expected a function", + }); const AgentOsOptionsSchema = z.custom( (val) => typeof val === "object" && val !== null, @@ -18,6 +23,7 @@ const AgentOsOptionsSchema = z.custom( export const agentOsActorConfigSchema = z .object({ options: AgentOsOptionsSchema.optional(), + createOptions: zFunction().optional(), preview: z .object({ defaultExpiresInSeconds: z.number().positive().default(3600), @@ -29,17 +35,25 @@ export const agentOsActorConfigSchema = z onSessionEvent: zFunction().optional(), onPermissionRequest: zFunction().optional(), }) - .strict(); + .strict() + .refine( + (data) => + (data.options !== undefined) !== (data.createOptions !== undefined), + { + message: + "agentOs config must define exactly one of 'options' or 'createOptions'", + }, + ); // --- Typed config types (generic callbacks overlaid on the Zod schema) --- -type AgentOsActorContext = ActorContext< +export type AgentOsContext = ActorContext< AgentOsActorState, TConnParams, undefined, AgentOsActorVars, undefined, - any + DatabaseProvider >; interface AgentOsActorConfigCallbacks { @@ -48,32 +62,63 @@ interface AgentOsActorConfigCallbacks { AgentOsActorState, AgentOsActorVars, undefined, - any + DatabaseProvider >, params: TConnParams, ) => void | Promise; onSessionEvent?: ( - c: AgentOsActorContext, + c: AgentOsContext, sessionId: string, event: JsonRpcNotification, ) => void | Promise; onPermissionRequest?: ( - c: AgentOsActorContext, + c: AgentOsContext, sessionId: string, request: PermissionRequest, ) => void | Promise; } +// Exclusive union: exactly one of `options` (static) or `createOptions` +// (per-actor-instance factory). Mirrors the sandboxActor pattern of +// `provider` / `createProvider`. +type AgentOsActorOptionsConfig = + | { + /** Static VM options shared by all instances of this actor. Use + * `createOptions` instead if each instance needs its own sandbox, + * filesystem mounts, or per-instance configuration. */ + options: AgentOsOptions; + createOptions?: never; + } + | { + options?: never; + /** Factory called lazily on first VM access. Receives the actor + * context so options can vary per instance (e.g., dedicated + * sandboxes). Mutually exclusive with `options`. May be async. */ + createOptions: ( + c: AgentOsContext, + ) => AgentOsOptions | Promise; + }; + // Parsed config (after Zod defaults/transforms applied). export type AgentOsActorConfig = Omit< z.infer, - "onBeforeConnect" | "onSessionEvent" | "onPermissionRequest" + | "options" + | "createOptions" + | "onBeforeConnect" + | "onSessionEvent" + | "onPermissionRequest" > & - AgentOsActorConfigCallbacks; + AgentOsActorConfigCallbacks & + AgentOsActorOptionsConfig; // Input config (what users pass in before Zod transforms). export type AgentOsActorConfigInput = Omit< z.input, - "onBeforeConnect" | "onSessionEvent" | "onPermissionRequest" + | "options" + | "createOptions" + | "onBeforeConnect" + | "onSessionEvent" + | "onPermissionRequest" > & - AgentOsActorConfigCallbacks; + AgentOsActorConfigCallbacks & + AgentOsActorOptionsConfig; diff --git a/rivetkit-typescript/packages/rivetkit/src/agent-os/index.ts b/rivetkit-typescript/packages/rivetkit/src/agent-os/index.ts index 6c919494e9..ddfa7e6e6b 100644 --- a/rivetkit-typescript/packages/rivetkit/src/agent-os/index.ts +++ b/rivetkit-typescript/packages/rivetkit/src/agent-os/index.ts @@ -1,11 +1,18 @@ // Database migration + +// Cron actions +export { buildCronActions } from "./actor/cron"; export { migrateAgentOsTables } from "./actor/db"; -// SQLite-backed VFS -export { createSqliteVfs, type SqliteVfsOptions } from "./fs/sqlite-vfs"; // Filesystem actions export { buildFilesystemActions } from "./actor/filesystem"; // Actor factory and VM lifecycle helpers export { agentOs, ensureVm, runHook, syncPreventSleep } from "./actor/index"; +// Network actions +export { + buildNetworkActions, + type VmFetchOptions, + type VmFetchResult, +} from "./actor/network"; // Preview actions export { buildOnRequestHandler, @@ -24,20 +31,15 @@ export { } from "./actor/session"; // Shell actions export { buildShellActions } from "./actor/shell"; -// Cron actions -export { buildCronActions } from "./actor/cron"; -// Network actions -export { - buildNetworkActions, - type VmFetchOptions, - type VmFetchResult, -} from "./actor/network"; // Config schema and types export { type AgentOsActorConfig, type AgentOsActorConfigInput, + type AgentOsContext, agentOsActorConfigSchema, } from "./config"; +// SQLite-backed VFS +export { createSqliteVfs, type SqliteVfsOptions } from "./fs/sqlite-vfs"; // Domain types and event payloads export type { AgentOsActionContext, @@ -45,12 +47,12 @@ export type { AgentOsActorVars, AgentOsEvents, CronEventPayload, + PermissionRequestPayload, PersistedSessionEvent, PersistedSessionRecord, - PermissionRequestPayload, - PromptResult, ProcessExitPayload, ProcessOutputPayload, + PromptResult, SerializableCronAction, SerializableCronJobOptions, SessionEventPayload, diff --git a/rivetkit-typescript/packages/rivetkit/src/agent-os/types.ts b/rivetkit-typescript/packages/rivetkit/src/agent-os/types.ts index 90b0eb6b23..8dd0c47486 100644 --- a/rivetkit-typescript/packages/rivetkit/src/agent-os/types.ts +++ b/rivetkit-typescript/packages/rivetkit/src/agent-os/types.ts @@ -8,16 +8,26 @@ import type { PermissionRequest, } from "@rivet-dev/agent-os-core"; import type { ActionContext } from "@/actor/contexts"; +import type { DatabaseProvider } from "@/actor/database"; +import type { RawAccess } from "@/db/config"; // --- Actor state (persisted across sleep/wake) --- -// biome-ignore lint/complexity/noBannedTypes: empty state placeholder, consumers extend via generics -export type AgentOsActorState = {}; +export interface AgentOsActorState { + /** Sandbox ID persisted across sleep/wake so the `createOptions` + * callback can reconnect to the same sandbox instead of provisioning + * a new one. Format is `"{provider}/{rawId}"` (e.g. `"docker/abc123"`). + * Set by the user inside `createOptions`; read back on subsequent wakes. */ + sandboxId: string | null; +} // --- Actor vars (ephemeral, recreated on wake) --- export interface AgentOsActorVars { agentOs: AgentOs | null; + /** In-flight VM boot promise used to prevent concurrent ensureVm calls from + * creating duplicate VMs. Reset on sleep/wake and cleared on boot failure. */ + vmBootGuard: Promise | null; activeSessionIds: Set; activeProcesses: Set; activeHooks: Set>; @@ -133,5 +143,5 @@ export type AgentOsActionContext = ActionContext< undefined, AgentOsActorVars, undefined, - any + DatabaseProvider >; diff --git a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-agent-os.ts b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-agent-os.ts index 07e053f129..fd62830146 100644 --- a/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-agent-os.ts +++ b/rivetkit-typescript/packages/rivetkit/src/driver-test-suite/tests/actor-agent-os.ts @@ -96,7 +96,9 @@ export function runActorAgentOsTests(driverTestConfig: DriverTestConfig) { await actor.writeFile("/home/user/todelete.txt", "gone"); await actor.deleteFile("/home/user/todelete.txt"); - expect(await actor.exists("/home/user/todelete.txt")).toBe(false); + expect(await actor.exists("/home/user/todelete.txt")).toBe( + false, + ); }, 60_000); test("writeFiles and readFiles batch operations", async (c) => { @@ -118,12 +120,12 @@ export function runActorAgentOsTests(driverTestConfig: DriverTestConfig) { "/home/user/batch-a.txt", "/home/user/batch-b.txt", ]); - expect( - new TextDecoder().decode(readResults[0].content), - ).toBe("aaa"); - expect( - new TextDecoder().decode(readResults[1].content), - ).toBe("bbb"); + expect(new TextDecoder().decode(readResults[0].content)).toBe( + "aaa", + ); + expect(new TextDecoder().decode(readResults[1].content)).toBe( + "bbb", + ); }, 60_000); test("readdirRecursive lists nested files", async (c) => { @@ -172,10 +174,7 @@ export function runActorAgentOsTests(driverTestConfig: DriverTestConfig) { ]); // Write a script that exits with code 42. - await actor.writeFile( - "/tmp/exit42.js", - 'process.exit(42);', - ); + await actor.writeFile("/tmp/exit42.js", "process.exit(42);"); const { pid } = await actor.spawn("node", ["/tmp/exit42.js"]); expect(typeof pid).toBe("number"); @@ -196,7 +195,7 @@ export function runActorAgentOsTests(driverTestConfig: DriverTestConfig) { // Write a long-running script. await actor.writeFile( "/tmp/long.js", - 'setTimeout(() => {}, 30000);', + "setTimeout(() => {}, 30000);", ); const { pid } = await actor.spawn("node", ["/tmp/long.js"]); @@ -217,7 +216,7 @@ export function runActorAgentOsTests(driverTestConfig: DriverTestConfig) { await actor.writeFile( "/tmp/hang.js", - 'setTimeout(() => {}, 60000);', + "setTimeout(() => {}, 60000);", ); const { pid } = await actor.spawn("node", ["/tmp/hang.js"]); @@ -262,7 +261,9 @@ server.listen(9876, "127.0.0.1", () => { "http://127.0.0.1:9876/test", ); expect(result.status).toBe(200); - expect(new TextDecoder().decode(result.body)).toBe("vm-response"); + expect(new TextDecoder().decode(result.body)).toBe( + "vm-response", + ); }, 60_000); // --- Cron --- @@ -289,6 +290,68 @@ server.listen(9876, "127.0.0.1", () => { const jobsAfter = await actor.listCronJobs(); expect(jobsAfter.some((j: any) => j.id === id)).toBe(false); }, 60_000); + + // --- createOptions factory path --- + + test("createOptions: writeFile and readFile round-trip", async (c) => { + const { client } = await setupDriverTest(c, { + ...driverTestConfig, + useRealTimers: true, + }); + const actor = client.agentOsCreateOptionsTestActor.getOrCreate([ + `create-opts-fs-${crypto.randomUUID()}`, + ]); + + await actor.writeFile( + "/home/user/hello.txt", + "hello from createOptions", + ); + const data = await actor.readFile("/home/user/hello.txt"); + expect(new TextDecoder().decode(data)).toBe( + "hello from createOptions", + ); + }, 60_000); + + test("createOptions: exec runs a command", async (c) => { + const { client } = await setupDriverTest(c, { + ...driverTestConfig, + useRealTimers: true, + }); + const actor = client.agentOsCreateOptionsTestActor.getOrCreate([ + `create-opts-exec-${crypto.randomUUID()}`, + ]); + + const result = await actor.exec("echo factory-path"); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe("factory-path"); + }, 60_000); + + test("createOptions: two instances get independent VMs", async (c) => { + const { client } = await setupDriverTest(c, { + ...driverTestConfig, + useRealTimers: true, + }); + + const actorA = client.agentOsCreateOptionsTestActor.getOrCreate( + [`create-opts-iso-a-${crypto.randomUUID()}`], + ); + const actorB = client.agentOsCreateOptionsTestActor.getOrCreate( + [`create-opts-iso-b-${crypto.randomUUID()}`], + ); + + // Write a file only in actor A. + await actorA.writeFile("/home/user/only-in-a.txt", "a-data"); + + // Actor B should not see it because each instance has its own VM. + expect(await actorB.exists("/home/user/only-in-a.txt")).toBe( + false, + ); + + // Actor A should still see it. + expect(await actorA.exists("/home/user/only-in-a.txt")).toBe( + true, + ); + }, 60_000); }, ); } diff --git a/rivetkit-typescript/packages/rivetkit/tests/actor-types.test.ts b/rivetkit-typescript/packages/rivetkit/tests/actor-types.test.ts index 905748bc2b..d527b34e99 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/actor-types.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/actor-types.test.ts @@ -1,7 +1,10 @@ +import type { AgentOsOptions } from "@rivet-dev/agent-os-core"; import { describe, expectTypeOf, it } from "vitest"; -import { actor, event, queue } from "@/actor/mod"; import type { ActorContext, ActorContextOf } from "@/actor/contexts"; import type { ActorDefinition } from "@/actor/definition"; +import { actor, event, queue } from "@/actor/mod"; +import type { AgentOsActorConfigInput } from "@/agent-os/config"; +import { agentOs } from "@/agent-os/index"; import type { ActorConn, ActorHandle } from "@/client/mod"; import type { DatabaseProviderContext } from "@/db/config"; import { db } from "@/db/mod"; @@ -563,3 +566,87 @@ describe("ActorDefinition", () => { }); }); }); + +describe("agentOs config exclusive union types", () => { + it("accepts options-only config", () => { + const config: AgentOsActorConfigInput = { + options: { software: [] }, + }; + expectTypeOf(config).toMatchTypeOf(); + }); + + it("accepts createOptions-only config", () => { + const config: AgentOsActorConfigInput = { + createOptions: () => ({ software: [] }), + }; + expectTypeOf(config).toMatchTypeOf(); + }); + + it("rejects config with both options and createOptions", () => { + // @ts-expect-error options and createOptions are mutually exclusive + const config: AgentOsActorConfigInput = { + options: { software: [] }, + createOptions: () => ({ software: [] }), + }; + }); + + it("rejects config with neither options nor createOptions", () => { + // @ts-expect-error must provide one of options or createOptions + const config: AgentOsActorConfigInput = {}; + }); + + it("createOptions callback receives context and returns AgentOsOptions", () => { + const config: AgentOsActorConfigInput = { + createOptions: (c) => { + expectTypeOf(c.log).not.toBeAny(); + expectTypeOf(c.vars).not.toBeAny(); + return { software: [] }; + }, + }; + }); + + it("createOptions callback has typed db access", () => { + const config: AgentOsActorConfigInput = { + createOptions: (c) => { + expectTypeOf(c.db).not.toBeAny(); + return { software: [] }; + }, + }; + }); + + it("createOptions callback can be async", () => { + const config: AgentOsActorConfigInput = { + createOptions: async (c) => { + return { software: [] }; + }, + }; + }); + + it("agentOs() accepts createOptions config without error", () => { + // This is a compile-time-only test. If it compiles, the types work. + agentOs({ + createOptions: async () => ({ software: [] }), + }); + }); + + it("agentOs() with connParams types the createOptions context", () => { + agentOs<{ userId: string }>({ + createOptions: async (c) => { + // The context should be typed, not any. + expectTypeOf(c).not.toBeAny(); + return { software: [] }; + }, + }); + }); + + it("createOptions callback can read and write c.state.sandboxId", () => { + const config: AgentOsActorConfigInput = { + createOptions: (c) => { + // sandboxId should be typed as string | null. + expectTypeOf(c.state.sandboxId).toEqualTypeOf(); + c.state.sandboxId = "docker/abc123"; + return { software: [] }; + }, + }; + }); +}); diff --git a/website/src/content/docs/agent-os/configuration.mdx b/website/src/content/docs/agent-os/configuration.mdx index 59716654ce..654ecb4e43 100644 --- a/website/src/content/docs/agent-os/configuration.mdx +++ b/website/src/content/docs/agent-os/configuration.mdx @@ -4,7 +4,11 @@ description: "Configure the agentOS VM options, preview settings, and lifecycle skill: true --- -`agentOs()` accepts the following configuration object. +`agentOs()` accepts the following configuration object. You must provide exactly one of `options` or `createOptions`. + +## Static options + +Use `options` when the same VM configuration works for all actor instances. ```ts @nocheck import { agentOs } from "rivetkit/agent-os"; @@ -51,6 +55,30 @@ export const registry = setup({ use: { vm } }); registry.start(); ``` +## Dynamic options + +Use `createOptions` when each actor instance needs its own VM configuration. The callback receives the actor context and runs lazily on first VM access. It may be async. + +This is useful for [sandbox mounting](/docs/agent-os/sandbox) where each instance needs a dedicated container, or for any scenario where options depend on the actor instance. + +`options` and `createOptions` are **mutually exclusive**. Providing both causes a validation error. + +```ts @nocheck +import { agentOs } from "rivetkit/agent-os"; +import { setup } from "rivetkit"; +import common from "@rivet-dev/agent-os-common"; + +const vm = agentOs({ + createOptions: async (c) => ({ + software: [common], + env: { TENANT_ID: c.key[0] }, + }), +}); + +export const registry = setup({ use: { vm } }); +registry.start(); +``` + ## Session options Options passed to `createSession`. See [Sessions](/docs/agent-os/sessions) for full documentation. diff --git a/website/src/content/docs/agent-os/index.mdx b/website/src/content/docs/agent-os/index.mdx index 39fa75efdb..03cc26c34c 100644 --- a/website/src/content/docs/agent-os/index.mdx +++ b/website/src/content/docs/agent-os/index.mdx @@ -390,13 +390,17 @@ agentOS uses a hybrid model: agents run in a lightweight VM by default and spin import { agentOs } from "rivetkit/agent-os"; import { setup } from "rivetkit"; import common from "@rivet-dev/agent-os-common"; -import pi from "@rivet-dev/agent-os-pi"; +import { docker } from "sandbox-agent/docker"; const vm = agentOs({ - options: { software: [common, pi], - sandbox: { - enabled: true, - }, + createOptions: async (c) => { + const { fs, toolkit } = await c.getSandbox({ provider: docker() }); + + return { + software: [common], + mounts: [{ path: "/sandbox", driver: fs }], + toolKits: [toolkit], + }; }, }); diff --git a/website/src/content/docs/agent-os/sandbox.mdx b/website/src/content/docs/agent-os/sandbox.mdx index 250c45a988..8b34a377ba 100644 --- a/website/src/content/docs/agent-os/sandbox.mdx +++ b/website/src/content/docs/agent-os/sandbox.mdx @@ -26,45 +26,51 @@ See [agentOS vs Sandbox](/docs/agent-os/versus-sandbox) for a detailed compariso ## Getting started -The `@rivet-dev/agent-os-sandbox` package integrates through two mechanisms: +Use `c.getSandbox()` inside `createOptions` to provision a sandbox per actor instance. It returns a filesystem driver and a toolkit you can pass directly to the VM options. -- **Filesystem mount**: Projects the sandbox into the VM as a native directory, like mounting a hard drive on your own machine. Read and write files through the mount directly. -- **Toolkit**: Exposes sandbox process management as [host tools](/docs/agent-os/tools). Execute commands on the sandbox from within the VM. - -Both are powered by [Sandbox Agent](https://sandboxagent.dev), so you can swap providers without changing agent code. +The sandbox ID is persisted internally across sleep/wake cycles, so the actor reconnects to the same container automatically. The sandbox is also auto-destroyed when the actor is destroyed. ```bash -npm install @rivet-dev/agent-os-sandbox sandbox-agent +npm install sandbox-agent ``` ```ts @nocheck -import { SandboxAgent } from "sandbox-agent"; -import { DockerProvider } from "sandbox-agent/docker"; -import { AgentOs } from "@rivet-dev/agent-os-core"; +import { agentOs } from "rivetkit/agent-os"; +import { setup } from "rivetkit"; import common from "@rivet-dev/agent-os-common"; -import { createSandboxFs, createSandboxToolkit } from "@rivet-dev/agent-os-sandbox"; - -const sandbox = await SandboxAgent.start({ - sandbox: new DockerProvider(), +import { docker } from "sandbox-agent/docker"; + +const vm = agentOs({ + createOptions: async (c) => { + const { fs, toolkit } = await c.getSandbox({ provider: docker() }); + + return { + software: [common], + mounts: [{ path: "/sandbox", driver: fs }], + toolKits: [toolkit], + }; + }, }); -const vm = await AgentOs.create({ - software: [common], - mounts: [ - { - path: "/sandbox", - driver: createSandboxFs({ client: sandbox }), - }, - ], - toolKits: [createSandboxToolkit({ client: sandbox })], -}); +export const registry = setup({ use: { vm } }); +registry.start(); +``` + +From a client, interact with the sandbox through the actor: + +```ts @nocheck +import { createClient } from "rivetkit/client"; +import type { registry } from "./server.ts"; + +const client = createClient("http://localhost:6420"); +const agent = client.vm.getOrCreate(["my-agent"]); // Write code via the filesystem. The /sandbox mount maps to the sandbox root. -await vm.writeFile("/sandbox/app/index.ts", 'console.log("hello")'); +await agent.writeFile("/sandbox/app/index.ts", 'console.log("hello")'); // Run it via the toolkit. Commands execute inside the sandbox, so paths are // relative to the sandbox root (/app/index.ts), not the VM mount (/sandbox/app/index.ts). -const result = await vm.exec("agentos-sandbox run-command --command node --json '{\"args\": [\"/app/index.ts\"]}'"); +const result = await agent.exec("agentos-sandbox run-command --command node --json '{\"args\": [\"/app/index.ts\"]}'"); ``` ## Tools reference @@ -94,7 +100,7 @@ agentos-sandbox send-input --id "proc_abc123" --input "yes" ## Sandbox providers -The extension works with any [Sandbox Agent](https://sandboxagent.dev) provider. See the [Sandbox Agent documentation](https://sandboxagent.dev) for available providers and setup instructions. +Sandbox mounting works with any [Sandbox Agent](https://sandboxagent.dev) provider. See the [Sandbox Agent documentation](https://sandboxagent.dev) for available providers and setup instructions. ## Recommendations