Skip to content
Open
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: 3 additions & 2 deletions packages/agentos/.gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Built inspector-tabs app bundle (generated by `vite build`)
assets/
# Built inspector-tabs app bundle (generated by `vite build`). Anchored so
# it does not swallow src/inspector-tabs/assets/ (tracked SVG sources).
/assets/

# Scheduled task lock
.claude/scheduled_tasks.lock
154 changes: 154 additions & 0 deletions packages/agentos/src/actor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import crypto from "node:crypto";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
AgentOs,
type AgentExitEvent,
Expand All @@ -25,6 +27,9 @@ import type {
AgentOsEvents,
ProcessExitPayload,
ProcessOutputPayload,
RuntimeAgentExit,
RuntimeHealth,
RuntimeLimitWarning,
SerializableCronJobOptions,
ShellDataPayload,
ShellExitPayload,
Expand Down Expand Up @@ -107,6 +112,34 @@ interface RuntimeState {

const runtimes = new Map<string, RuntimeState>();

// Bounded post-mortem buffers for the observe-only `health` action. Kept in
// their own map — not on RuntimeState — because `disposeVm` drops the runtime
// entry on sleep while these must stay readable while `booted` is false. They
// are cleared only when the actor is destroyed.
const HEALTH_WARNINGS_CAP = 50;
const HEALTH_AGENT_EXITS_CAP = 50;

interface HealthBuffers {
warnings: RuntimeLimitWarning[];
agentExits: RuntimeAgentExit[];
}

const healthBuffers = new Map<string, HealthBuffers>();

function healthBuffersFor(actorId: string): HealthBuffers {
let buffers = healthBuffers.get(actorId);
if (!buffers) {
buffers = { warnings: [], agentExits: [] };
healthBuffers.set(actorId, buffers);
}
return buffers;
}

function pushCapped<T>(buffer: T[], item: T, cap: number): void {
buffer.push(item);
while (buffer.length > cap) buffer.shift();
}

function runtimeFor(c: AnyContext): RuntimeState {
let runtime = runtimes.get(c.actorId);
if (!runtime) {
Expand Down Expand Up @@ -150,6 +183,18 @@ async function ensureVm(
msg: "agent-os agent adapter exited unexpectedly",
...event,
});
pushCapped(
healthBuffersFor(c.actorId).agentExits,
{
ts: Date.now(),
sessionId: event.sessionId,
agentType: event.agentType,
exitCode: event.exitCode,
restart: event.restart,
restartCount: event.restartCount,
},
HEALTH_AGENT_EXITS_CAP,
);
c.broadcast("agentExit", event);
try {
options?.onAgentExit?.(event);
Expand All @@ -161,6 +206,26 @@ async function ensureVm(
});
}
},
onLimitWarning: (warning) => {
c.log.warn({
msg: "agent-os runtime limit is near capacity",
...warning,
});
pushCapped(
healthBuffersFor(c.actorId).warnings,
{ ts: Date.now(), ...warning },
HEALTH_WARNINGS_CAP,
);
try {
options?.onLimitWarning?.(warning);
} catch (error) {
c.log.error({
msg: "agent-os onLimitWarning hook failed",
limit: warning.limit,
error,
});
}
},
rootFilesystem: {
type: "native",
plugin: {
Expand Down Expand Up @@ -206,6 +271,9 @@ async function ensureVm(
}

async function disposeVm(c: AnyContext, reason: "sleep" | "destroy" | "error") {
// Post-mortem health buffers survive sleep and error by design; only a
// destroyed actor drops them.
if (reason === "destroy") healthBuffers.delete(c.actorId);
const runtime = runtimes.get(c.actorId);
if (!runtime) return;
const vm = runtime.vm;
Expand Down Expand Up @@ -638,6 +706,41 @@ export function createAgentOsActions(
) => (await ensureVm(c, options)).cancelCronJob(...args),
listAgents: async (c: AnyContext) =>
(await ensureVm(c, options)).listAgents(),
// Observe-only: reports VM liveness and the post-mortem buffers without
// ever booting the VM (deliberately no ensureVm).
health: async (c: AnyContext): Promise<RuntimeHealth> => {
const runtime = runtimes.get(c.actorId);
const buffers = healthBuffersFor(c.actorId);
let booted = false;
let sessions: number | null = null;
let sidecar: RuntimeHealth["sidecar"] = null;
if (runtime?.vm) {
// Sample the boot promise without waiting on it: a VM still
// booting (or one whose boot failed) reports not-booted rather
// than blocking the observe-only action.
const vm = await Promise.race([
runtime.vm.catch(() => null),
Promise.resolve(null),
]);
if (vm) {
booted = true;
sessions = runtime.subscribedSessions.size;
const description = vm.sidecar.describe();
sidecar = {
state: description.state,
activeVmCount: description.activeVmCount,
};
}
}
return {
booted,
sessions,
sidecar,
warnings: [...buffers.warnings],
agentExits: [...buffers.agentExits],
stderrTail: [],
};
},
openSession: async (c: AnyContext, input: OpenSessionInput) => {
const vm = await ensureVm(c, options);
await vm.openSession(input);
Expand Down Expand Up @@ -1012,6 +1115,50 @@ function assertNoReservedKeys(
}
}

// Absolute path to the built inspector-tabs app (the shared Vite bundle). All
// custom tabs share this one `source` dir; the app routes on the
// `/inspector/custom-tabs/<id>/` URL segment. Resolves from both `src/` (tsx
// dev) and the published `dist/`, since `assets/` sits at the package root in
// both layouts.
const INSPECTOR_TABS_ASSET_DIR = join(
dirname(fileURLToPath(import.meta.url)),
"..",
"assets",
"inspector-tabs-app",
);

// Custom inspector tabs shipped by agent-os. Ids MUST match the `TABS`
// registry in `src/inspector-tabs/main.tsx`. The built-in rivetkit tabs are
// hidden so the dashboard shows only the agent-os tabs.
const AGENTOS_INSPECTOR_CONFIG = {
tabs: [
{
id: "transcript",
label: "Transcript",
source: INSPECTOR_TABS_ASSET_DIR,
icon: "comments",
},
{
id: "filesystem",
label: "Filesystem",
source: INSPECTOR_TABS_ASSET_DIR,
icon: "folder-tree",
},
{
id: "system",
label: "System",
source: INSPECTOR_TABS_ASSET_DIR,
icon: "layer-group",
},
// Processes/software/mounts live as sections inside the System tab.
// `metadata` is dashboard-owned and not hideable (the schema only
// accepts the six built-in ids below), so it stays.
...["workflow", "database", "state", "queue", "connections", "console"].map(
(id) => ({ id, hidden: true as const }),
),
],
};

export function createAgentOS<
TState = undefined,
TConnParams = undefined,
Expand Down Expand Up @@ -1108,6 +1255,13 @@ export function createAgentOS<
sleepGracePeriod: DEFAULT_SLEEP_GRACE_PERIOD_MS,
...actorConfig.options,
},
// Register the custom agent-os inspector tabs (and hide the built-in
// rivetkit tabs) so the dashboard renders the agent-os UI. Without
// this the shipped tab assets are never surfaced. A caller-supplied
// inspector config wins.
inspector:
(actorConfig as { inspector?: unknown }).inspector ??
AGENTOS_INSPECTOR_CONFIG,
db: db({ onMigrate: migrateAgentOsActorTables }),
events: { ...(actorConfig.events ?? {}), ...builtInEvents },
actions: { ...(actorConfig.actions ?? {}), ...actions },
Expand Down
5 changes: 5 additions & 0 deletions packages/agentos/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export type {
AgentOsEvents,
ProcessExitPayload,
ProcessOutputPayload,
RuntimeAgentExit,
RuntimeHealth,
RuntimeLimitWarning,
RuntimeSidecarInfo,
RuntimeStderrLine,
SerializableCronAction,
SerializableCronEvent,
SerializableCronJobInfo,
Expand Down
Loading
Loading