426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
934 lines
31 KiB
TypeScript
934 lines
31 KiB
TypeScript
/**
|
|
* Shared Cloud Agent Logic
|
|
*
|
|
* Single implementation of the cloud-agent runtime, health server, and
|
|
* bridge server. Both the main entrypoint and the template entrypoint
|
|
* import from here, passing a config that captures the small differences.
|
|
*/
|
|
|
|
import * as crypto from "node:crypto";
|
|
import * as http from "node:http";
|
|
import restartExitCodeDefinition from "../../shared/src/restart-exit-code.json" with {
|
|
type: "json",
|
|
};
|
|
|
|
const CLOUD_AGENT_RESTART_EXIT_CODE = restartExitCodeDefinition.restartExitCode;
|
|
|
|
// ─── Logger ─────────────────────────────────────────────────────────────
|
|
//
|
|
// This file is bundled with `--external:@elizaos/*` and runs in a minimal
|
|
// container before any `@elizaos/*` package is statically resolvable, so it
|
|
// cannot import the shared structured logger. This tiny shim gives the same
|
|
// `[ClassName]`-prefixed, level-tagged, single-line structured output the
|
|
// logger commandment requires (message + optional context object), without a
|
|
// hard dependency. All cloud-agent runtime logging goes through it.
|
|
const logger = {
|
|
info(message: string, context?: Record<string, unknown>): void {
|
|
if (context) console.log(`[cloud-agent] ${message}`, context);
|
|
else console.log(`[cloud-agent] ${message}`);
|
|
},
|
|
warn(message: string, context?: Record<string, unknown>): void {
|
|
if (context) console.warn(`[cloud-agent] ${message}`, context);
|
|
else console.warn(`[cloud-agent] ${message}`);
|
|
},
|
|
error(message: string, context?: Record<string, unknown>): void {
|
|
if (context) console.error(`[cloud-agent] ${message}`, context);
|
|
else console.error(`[cloud-agent] ${message}`);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* `.catch` handler for an optional plugin dynamic import: keeps the degrade
|
|
* (agent boots without the plugin) but surfaces the import failure so a broken
|
|
* build is distinguishable from a plugin that is deliberately absent in this
|
|
* deploy shape.
|
|
*/
|
|
// error-policy:J4 optional plugin unavailable → degrade; failure surfaced via logger
|
|
function logPluginLoadFailure(id: string): (error: unknown) => null {
|
|
return (error) => {
|
|
logger.error(`failed to load ${id}; continuing without it`, {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
return null;
|
|
};
|
|
}
|
|
|
|
// ─── Types ──────────────────────────────────────────────────────────────
|
|
|
|
export interface BridgeRpcParams {
|
|
text?: string;
|
|
roomId?: string;
|
|
mode?: string;
|
|
channelType?: string;
|
|
source?: string;
|
|
sender?: {
|
|
id?: string;
|
|
username?: string;
|
|
displayName?: string;
|
|
metadata?: Record<string, unknown>;
|
|
};
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
export type NormalizedBridgeMessage = {
|
|
text: string;
|
|
roomKey: string;
|
|
mode: "simple" | "power";
|
|
channelType: "DM" | "GROUP";
|
|
source: string;
|
|
sender?: {
|
|
id?: string;
|
|
username?: string;
|
|
displayName?: string;
|
|
metadata?: Record<string, unknown>;
|
|
};
|
|
metadata?: Record<string, unknown>;
|
|
};
|
|
|
|
export type BridgeMessageResult = {
|
|
text: string;
|
|
failureKind?: string;
|
|
};
|
|
|
|
type BridgeCallbackContent = {
|
|
text?: unknown;
|
|
failureKind?: unknown;
|
|
};
|
|
|
|
export function appendBridgeCallbackContent(
|
|
result: BridgeMessageResult,
|
|
content: BridgeCallbackContent,
|
|
): BridgeMessageResult {
|
|
if (typeof content.text === "string") result.text += content.text;
|
|
if (
|
|
!result.failureKind &&
|
|
typeof content.failureKind === "string" &&
|
|
content.failureKind.trim()
|
|
) {
|
|
result.failureKind = content.failureKind.trim();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function bridgeResultText(result: BridgeMessageResult): string {
|
|
return result.text || "(no response)";
|
|
}
|
|
|
|
export function normalizeBridgeMessage(
|
|
params?: BridgeRpcParams,
|
|
): NormalizedBridgeMessage {
|
|
const trimmedRoomId =
|
|
typeof params?.roomId === "string" && params.roomId.trim().length > 0
|
|
? params.roomId.trim()
|
|
: "default";
|
|
const source =
|
|
typeof params?.source === "string" && params.source.trim().length > 0
|
|
? params.source.trim()
|
|
: "cloud-bridge";
|
|
const sender =
|
|
params?.sender && typeof params.sender === "object"
|
|
? {
|
|
...(typeof params.sender.id === "string" && params.sender.id.trim()
|
|
? { id: params.sender.id.trim() }
|
|
: {}),
|
|
...(typeof params.sender.username === "string" &&
|
|
params.sender.username.trim()
|
|
? { username: params.sender.username.trim() }
|
|
: {}),
|
|
...(typeof params.sender.displayName === "string" &&
|
|
params.sender.displayName.trim()
|
|
? { displayName: params.sender.displayName.trim() }
|
|
: {}),
|
|
...(params.sender.metadata &&
|
|
typeof params.sender.metadata === "object" &&
|
|
!Array.isArray(params.sender.metadata)
|
|
? { metadata: params.sender.metadata }
|
|
: {}),
|
|
}
|
|
: undefined;
|
|
|
|
return {
|
|
text: typeof params?.text === "string" ? params.text : "",
|
|
roomKey: trimmedRoomId,
|
|
mode: params?.mode === "simple" ? "simple" : "power",
|
|
channelType: params?.channelType === "GROUP" ? "GROUP" : "DM",
|
|
source,
|
|
...(sender && Object.keys(sender).length > 0 ? { sender } : {}),
|
|
...(params?.metadata &&
|
|
typeof params.metadata === "object" &&
|
|
!Array.isArray(params.metadata)
|
|
? { metadata: params.metadata }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
export interface CloudAgentConfig {
|
|
/** Health endpoint port. Default: 2138 */
|
|
port?: number;
|
|
/** Bridge server port. Default: 18790 */
|
|
bridgePort?: number;
|
|
/**
|
|
* If set, the bridge server requires `Authorization: Bearer <secret>`.
|
|
* Omit or pass empty string to disable auth.
|
|
*/
|
|
bridgeSecret?: string;
|
|
/** Max request body size in bytes. Default: 1 MB */
|
|
maxBodyBytes?: number;
|
|
/** Max memories kept in state. 0 = unlimited. Default: 0 */
|
|
maxMemories?: number;
|
|
/**
|
|
* Whether processMessage/processMessageStream accept a chat mode param.
|
|
* When false the mode parameter is ignored (template behaviour).
|
|
*/
|
|
enableChatMode?: boolean;
|
|
}
|
|
|
|
interface AgentRuntime {
|
|
processMessage: (params: BridgeRpcParams) => Promise<BridgeMessageResult>;
|
|
processMessageStream: (
|
|
params: BridgeRpcParams,
|
|
onChunk: (chunk: string) => void,
|
|
) => Promise<BridgeMessageResult>;
|
|
getMemories: () => Array<Record<string, unknown>>;
|
|
getConfig: () => Record<string, unknown>;
|
|
}
|
|
|
|
// ─── Main entry ─────────────────────────────────────────────────────────
|
|
|
|
export function startCloudAgent(userConfig: CloudAgentConfig = {}): void {
|
|
const PORT = userConfig.port ?? Number(process.env.PORT ?? "2138");
|
|
const BRIDGE_PORT =
|
|
userConfig.bridgePort ?? Number(process.env.BRIDGE_PORT ?? "18790");
|
|
const BRIDGE_SECRET = userConfig.bridgeSecret || crypto.randomUUID();
|
|
const bridgeSecretGenerated = !userConfig.bridgeSecret;
|
|
const MAX_BODY_BYTES = userConfig.maxBodyBytes ?? 1_048_576;
|
|
const MAX_MEMORIES = userConfig.maxMemories ?? 0;
|
|
const enableChatMode = userConfig.enableChatMode ?? false;
|
|
|
|
let agentRuntime: AgentRuntime | null = null;
|
|
|
|
/** In-memory state that persists across snapshots. */
|
|
const state = {
|
|
memories: [] as Array<Record<string, unknown>>,
|
|
config: {} as Record<string, unknown>,
|
|
workspaceFiles: {} as Record<string, string>,
|
|
startedAt: new Date().toISOString(),
|
|
lastActivityAt: new Date().toISOString(),
|
|
};
|
|
|
|
/** Trim memories array to MAX_MEMORIES, removing oldest entries first. */
|
|
function trimMemories(): void {
|
|
if (MAX_MEMORIES > 0 && state.memories.length > MAX_MEMORIES) {
|
|
state.memories.splice(0, state.memories.length - MAX_MEMORIES);
|
|
}
|
|
}
|
|
|
|
function readBody(req: http.IncomingMessage): Promise<string> {
|
|
return new Promise<string>((resolve, reject) => {
|
|
let body = "";
|
|
let totalBytes = 0;
|
|
req.on("data", (chunk: Buffer) => {
|
|
totalBytes += chunk.length;
|
|
if (MAX_BODY_BYTES > 0 && totalBytes > MAX_BODY_BYTES) {
|
|
req.destroy();
|
|
reject(new Error("Request body too large"));
|
|
return;
|
|
}
|
|
body += chunk;
|
|
});
|
|
req.on("end", () => resolve(body));
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
// ─── elizaOS Runtime ──────────────────────────────────────────────────
|
|
|
|
async function initRuntime(): Promise<void> {
|
|
const elizaAvailable = await import("@elizaos/core")
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
|
|
if (elizaAvailable) {
|
|
const {
|
|
AgentRuntime: AgentRuntimeCtor,
|
|
createCharacter,
|
|
createMessageMemory,
|
|
stringToUuid,
|
|
ChannelType,
|
|
} = await import("@elizaos/core");
|
|
|
|
const character = createCharacter({
|
|
name: process.env.AGENT_NAME ?? "CloudAgent",
|
|
bio: "An elizaOS agent running in the cloud.",
|
|
settings: {
|
|
...(process.env.DATABASE_URL
|
|
? {
|
|
POSTGRES_URL: process.env.DATABASE_URL,
|
|
DATABASE_URL: process.env.DATABASE_URL,
|
|
}
|
|
: {}),
|
|
},
|
|
secrets: {
|
|
...(process.env.ELIZAOS_CLOUD_API_KEY
|
|
? { ELIZAOS_CLOUD_API_KEY: process.env.ELIZAOS_CLOUD_API_KEY }
|
|
: {}),
|
|
...(process.env.OPENAI_API_KEY
|
|
? { OPENAI_API_KEY: process.env.OPENAI_API_KEY }
|
|
: {}),
|
|
...(process.env.ANTHROPIC_API_KEY
|
|
? { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY }
|
|
: {}),
|
|
...(process.env.GOOGLE_API_KEY
|
|
? { GOOGLE_API_KEY: process.env.GOOGLE_API_KEY }
|
|
: {}),
|
|
...(process.env.XAI_API_KEY
|
|
? { XAI_API_KEY: process.env.XAI_API_KEY }
|
|
: {}),
|
|
...(process.env.GROQ_API_KEY
|
|
? { GROQ_API_KEY: process.env.GROQ_API_KEY }
|
|
: {}),
|
|
},
|
|
});
|
|
|
|
const plugins = [];
|
|
|
|
const cloudPlugin = await import("@elizaos/plugin-elizacloud")
|
|
.then((m) => m.default)
|
|
.catch(logPluginLoadFailure("@elizaos/plugin-elizacloud"));
|
|
if (cloudPlugin) plugins.push(cloudPlugin);
|
|
|
|
const sqlPlugin = await import("@elizaos/plugin-sql")
|
|
.then((m) => m.default)
|
|
.catch(logPluginLoadFailure("@elizaos/plugin-sql"));
|
|
if (sqlPlugin) plugins.push(sqlPlugin);
|
|
|
|
const workflowPlugin = await import("@elizaos/plugin-workflow")
|
|
.then((m) => m.default)
|
|
.catch(logPluginLoadFailure("@elizaos/plugin-workflow"));
|
|
if (workflowPlugin) plugins.push(workflowPlugin);
|
|
|
|
const runtime = new AgentRuntimeCtor({ character, plugins });
|
|
await runtime.initialize();
|
|
const runtimeWithBridge = runtime as typeof runtime & {
|
|
ensureWorldExists?: (world: Record<string, unknown>) => Promise<void>;
|
|
ensureRoomExists?: (room: Record<string, unknown>) => Promise<void>;
|
|
ensureParticipantInRoom?: (
|
|
entityId: ReturnType<typeof stringToUuid>,
|
|
roomId: ReturnType<typeof stringToUuid>,
|
|
) => Promise<void>;
|
|
getEntityById?: (
|
|
entityId: ReturnType<typeof stringToUuid>,
|
|
) => Promise<Record<string, unknown> | null>;
|
|
createEntity?: (entity: Record<string, unknown>) => Promise<void>;
|
|
updateEntity?: (entity: Record<string, unknown>) => Promise<void>;
|
|
};
|
|
|
|
const ensureBridgeContext = async (params: BridgeRpcParams) => {
|
|
const normalized = normalizeBridgeMessage(params);
|
|
const worldId = stringToUuid(
|
|
`${normalized.source}-${normalized.channelType.toLowerCase()}-world`,
|
|
);
|
|
const serverId = stringToUuid(`${normalized.source}-bridge-server`);
|
|
const roomId = stringToUuid(
|
|
`${normalized.source}-bridge-room-${normalized.roomKey}`,
|
|
);
|
|
const entityKey =
|
|
normalized.sender?.id ??
|
|
normalized.sender?.username ??
|
|
`${normalized.source}-bridge-user`;
|
|
const entityId = stringToUuid(
|
|
`${normalized.source}-bridge-user-${entityKey}`,
|
|
);
|
|
const channelType =
|
|
normalized.channelType === "GROUP"
|
|
? ChannelType.GROUP
|
|
: ChannelType.DM;
|
|
const displayName =
|
|
normalized.sender?.displayName ??
|
|
normalized.sender?.username ??
|
|
"BridgeUser";
|
|
|
|
if (typeof runtimeWithBridge.ensureWorldExists === "function") {
|
|
await runtimeWithBridge.ensureWorldExists({
|
|
id: worldId,
|
|
name: normalized.source === "discord" ? "Discord" : "Cloud Bridge",
|
|
agentId: runtime.agentId,
|
|
serverId,
|
|
});
|
|
}
|
|
|
|
if (typeof runtimeWithBridge.ensureRoomExists === "function") {
|
|
await runtimeWithBridge.ensureRoomExists({
|
|
id: roomId,
|
|
name: normalized.roomKey,
|
|
type: channelType,
|
|
channelId: normalized.roomKey,
|
|
worldId,
|
|
serverId,
|
|
agentId: runtime.agentId,
|
|
source: normalized.source,
|
|
});
|
|
}
|
|
|
|
const entityMetadata =
|
|
normalized.sender?.metadata &&
|
|
typeof normalized.sender.metadata === "object" &&
|
|
!Array.isArray(normalized.sender.metadata)
|
|
? normalized.sender.metadata
|
|
: undefined;
|
|
const entityPayload = {
|
|
id: entityId,
|
|
agentId: runtime.agentId,
|
|
names: Array.from(
|
|
new Set(
|
|
[displayName, normalized.sender?.username].filter(
|
|
(value): value is string => Boolean(value),
|
|
),
|
|
),
|
|
),
|
|
...(entityMetadata ? { metadata: entityMetadata } : {}),
|
|
};
|
|
|
|
try {
|
|
if (
|
|
typeof runtimeWithBridge.getEntityById === "function" &&
|
|
typeof runtimeWithBridge.updateEntity === "function"
|
|
) {
|
|
const existingEntity =
|
|
await runtimeWithBridge.getEntityById(entityId);
|
|
if (existingEntity) {
|
|
await runtimeWithBridge.updateEntity({
|
|
...existingEntity,
|
|
...entityPayload,
|
|
names:
|
|
entityPayload.names.length > 0
|
|
? entityPayload.names
|
|
: ((existingEntity.names as string[] | undefined) ?? []),
|
|
});
|
|
} else if (typeof runtimeWithBridge.createEntity === "function") {
|
|
await runtimeWithBridge.createEntity(entityPayload);
|
|
}
|
|
} else if (typeof runtimeWithBridge.createEntity === "function") {
|
|
await runtimeWithBridge.createEntity(entityPayload);
|
|
}
|
|
} catch {
|
|
// Best-effort entity sync. The room flow still works if the entity already exists.
|
|
}
|
|
|
|
if (typeof runtimeWithBridge.ensureParticipantInRoom === "function") {
|
|
await Promise.all([
|
|
runtimeWithBridge.ensureParticipantInRoom(runtime.agentId, roomId),
|
|
runtimeWithBridge.ensureParticipantInRoom(entityId, roomId),
|
|
]);
|
|
}
|
|
|
|
return { normalized, entityId, roomId, channelType };
|
|
};
|
|
|
|
agentRuntime = {
|
|
processMessage: async (
|
|
params: BridgeRpcParams,
|
|
): Promise<BridgeMessageResult> => {
|
|
const { normalized, entityId, roomId, channelType } =
|
|
await ensureBridgeContext(params);
|
|
const message = createMessageMemory({
|
|
id: crypto.randomUUID() as ReturnType<typeof stringToUuid>,
|
|
entityId,
|
|
roomId,
|
|
content: {
|
|
text: normalized.text,
|
|
...(enableChatMode
|
|
? {
|
|
mode: normalized.mode,
|
|
simple: normalized.mode === "simple",
|
|
}
|
|
: {}),
|
|
source: normalized.source,
|
|
channelType,
|
|
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
|
|
},
|
|
});
|
|
|
|
const response: BridgeMessageResult = { text: "" };
|
|
await runtime.messageService?.handleMessage(
|
|
runtime,
|
|
message,
|
|
async (content) => {
|
|
appendBridgeCallbackContent(
|
|
response,
|
|
content as BridgeCallbackContent,
|
|
);
|
|
return [];
|
|
},
|
|
);
|
|
|
|
state.lastActivityAt = new Date().toISOString();
|
|
state.memories.push({
|
|
role: "user",
|
|
text: normalized.text,
|
|
timestamp: Date.now(),
|
|
source: normalized.source,
|
|
roomId: normalized.roomKey,
|
|
});
|
|
state.memories.push({
|
|
role: "assistant",
|
|
text: bridgeResultText(response),
|
|
timestamp: Date.now(),
|
|
...(response.failureKind
|
|
? { failureKind: response.failureKind }
|
|
: {}),
|
|
});
|
|
trimMemories();
|
|
|
|
return {
|
|
text: bridgeResultText(response),
|
|
...(response.failureKind
|
|
? { failureKind: response.failureKind }
|
|
: {}),
|
|
};
|
|
},
|
|
processMessageStream: async (
|
|
params: BridgeRpcParams,
|
|
onChunk: (chunk: string) => void,
|
|
): Promise<BridgeMessageResult> => {
|
|
const { normalized, entityId, roomId, channelType } =
|
|
await ensureBridgeContext(params);
|
|
const message = createMessageMemory({
|
|
id: crypto.randomUUID() as ReturnType<typeof stringToUuid>,
|
|
entityId,
|
|
roomId,
|
|
content: {
|
|
text: normalized.text,
|
|
...(enableChatMode
|
|
? {
|
|
mode: normalized.mode,
|
|
simple: normalized.mode === "simple",
|
|
}
|
|
: {}),
|
|
source: normalized.source,
|
|
channelType,
|
|
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
|
|
},
|
|
});
|
|
|
|
const response: BridgeMessageResult = { text: "" };
|
|
await runtime.messageService?.handleMessage(
|
|
runtime,
|
|
message,
|
|
async (content) => {
|
|
const previousTextLength = response.text.length;
|
|
appendBridgeCallbackContent(
|
|
response,
|
|
content as BridgeCallbackContent,
|
|
);
|
|
if (response.text.length > previousTextLength) {
|
|
onChunk(response.text.slice(previousTextLength));
|
|
}
|
|
return [];
|
|
},
|
|
);
|
|
|
|
state.lastActivityAt = new Date().toISOString();
|
|
state.memories.push({
|
|
role: "user",
|
|
text: normalized.text,
|
|
timestamp: Date.now(),
|
|
source: normalized.source,
|
|
roomId: normalized.roomKey,
|
|
});
|
|
state.memories.push({
|
|
role: "assistant",
|
|
text: bridgeResultText(response),
|
|
timestamp: Date.now(),
|
|
...(response.failureKind
|
|
? { failureKind: response.failureKind }
|
|
: {}),
|
|
});
|
|
trimMemories();
|
|
|
|
return {
|
|
text: bridgeResultText(response),
|
|
...(response.failureKind
|
|
? { failureKind: response.failureKind }
|
|
: {}),
|
|
};
|
|
},
|
|
getMemories: () => state.memories,
|
|
getConfig: () => state.config,
|
|
};
|
|
|
|
logger.info("elizaOS runtime initialized with real agent");
|
|
} else {
|
|
logger.warn("@elizaos/core not available, running in echo mode");
|
|
agentRuntime = {
|
|
processMessage: async (
|
|
params: BridgeRpcParams,
|
|
): Promise<BridgeMessageResult> => {
|
|
const normalized = normalizeBridgeMessage(params);
|
|
state.memories.push({
|
|
role: "user",
|
|
text: normalized.text,
|
|
timestamp: Date.now(),
|
|
source: normalized.source,
|
|
roomId: normalized.roomKey,
|
|
});
|
|
const reply = `[echo] ${normalized.text}`;
|
|
state.memories.push({
|
|
role: "assistant",
|
|
text: reply,
|
|
timestamp: Date.now(),
|
|
});
|
|
trimMemories();
|
|
return { text: reply };
|
|
},
|
|
processMessageStream: async (
|
|
params: BridgeRpcParams,
|
|
onChunk: (chunk: string) => void,
|
|
): Promise<BridgeMessageResult> => {
|
|
const normalized = normalizeBridgeMessage(params);
|
|
state.memories.push({
|
|
role: "user",
|
|
text: normalized.text,
|
|
timestamp: Date.now(),
|
|
source: normalized.source,
|
|
roomId: normalized.roomKey,
|
|
});
|
|
const reply = `[echo] ${normalized.text}`;
|
|
onChunk(reply);
|
|
state.memories.push({
|
|
role: "assistant",
|
|
text: reply,
|
|
timestamp: Date.now(),
|
|
});
|
|
trimMemories();
|
|
return { text: reply };
|
|
},
|
|
getMemories: () => state.memories,
|
|
getConfig: () => state.config,
|
|
};
|
|
}
|
|
}
|
|
|
|
// ─── Health endpoint ──────────────────────────────────────────────────
|
|
|
|
/** Consider the runtime hung if no activity for 10 minutes after init. */
|
|
const HUNG_RUNTIME_THRESHOLD_MS = 10 * 60_000;
|
|
|
|
const healthServer = http.createServer((req, res) => {
|
|
if (
|
|
req.method === "GET" &&
|
|
(req.url === "/health" || req.url === "/api/health")
|
|
) {
|
|
const lastActivityAge =
|
|
Date.now() - new Date(state.lastActivityAt).getTime();
|
|
const possiblyHung =
|
|
agentRuntime !== null &&
|
|
state.memories.length > 0 &&
|
|
lastActivityAge > HUNG_RUNTIME_THRESHOLD_MS;
|
|
|
|
let status: string;
|
|
if (!agentRuntime) {
|
|
status = "initializing";
|
|
} else if (possiblyHung) {
|
|
status = "possibly_hung";
|
|
} else {
|
|
status = "healthy";
|
|
}
|
|
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(
|
|
JSON.stringify({
|
|
status,
|
|
uptime: process.uptime(),
|
|
startedAt: state.startedAt,
|
|
lastActivityAt: state.lastActivityAt,
|
|
memoryUsage: process.memoryUsage().rss,
|
|
runtimeReady: agentRuntime !== null,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (req.method === "GET" && req.url === "/") {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(
|
|
JSON.stringify({
|
|
service: "elizaos-cloud-agent",
|
|
status: "running",
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
res.writeHead(404);
|
|
res.end("Not Found");
|
|
});
|
|
|
|
healthServer.listen(PORT, "0.0.0.0", () => {
|
|
logger.info(`Health endpoint listening on port ${PORT}`);
|
|
});
|
|
|
|
// ─── Bridge HTTP server ───────────────────────────────────────────────
|
|
|
|
const bridgeServer = http.createServer(async (req, res) => {
|
|
res.setHeader("Content-Type", "application/json");
|
|
|
|
// Auth check (only when BRIDGE_SECRET is configured)
|
|
if (BRIDGE_SECRET) {
|
|
const authHeader = req.headers.authorization ?? "";
|
|
if (authHeader !== `Bearer ${BRIDGE_SECRET}`) {
|
|
res.writeHead(401);
|
|
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (req.method === "POST" && req.url === "/api/snapshot") {
|
|
res.writeHead(200);
|
|
res.end(
|
|
JSON.stringify({
|
|
memories: state.memories,
|
|
config: state.config,
|
|
workspaceFiles: state.workspaceFiles,
|
|
timestamp: new Date().toISOString(),
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && req.url === "/api/restore") {
|
|
const body = await readBody(req);
|
|
let incoming: Partial<typeof state>;
|
|
try {
|
|
incoming = JSON.parse(body) as Partial<typeof state>;
|
|
} catch {
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
return;
|
|
}
|
|
if (incoming.memories) state.memories = incoming.memories;
|
|
if (incoming.config) state.config = incoming.config;
|
|
if (incoming.workspaceFiles)
|
|
state.workspaceFiles = incoming.workspaceFiles;
|
|
logger.info("State restored from snapshot");
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({ success: true }));
|
|
return;
|
|
}
|
|
|
|
// ── SSE streaming endpoint ────────────────────────────────────────
|
|
if (req.method === "POST" && req.url === "/bridge/stream") {
|
|
if (!agentRuntime) {
|
|
res.writeHead(503, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "Agent runtime not ready" }));
|
|
return;
|
|
}
|
|
|
|
const body = await readBody(req);
|
|
let rpc: {
|
|
jsonrpc: string;
|
|
id?: string | number;
|
|
method?: string;
|
|
params?: BridgeRpcParams;
|
|
};
|
|
try {
|
|
rpc = JSON.parse(body);
|
|
} catch {
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
return;
|
|
}
|
|
|
|
if (rpc.method !== "message.send") {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "Only message.send is streamable" }));
|
|
return;
|
|
}
|
|
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
Connection: "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
});
|
|
|
|
const sendEvent = (event: string, data: unknown) => {
|
|
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
};
|
|
|
|
sendEvent("connected", { rpcId: rpc.id, timestamp: Date.now() });
|
|
|
|
const response = await agentRuntime.processMessageStream(
|
|
rpc.params ?? {},
|
|
(chunk: string) => {
|
|
sendEvent("chunk", { text: chunk });
|
|
},
|
|
);
|
|
|
|
sendEvent("done", {
|
|
rpcId: rpc.id,
|
|
timestamp: Date.now(),
|
|
...(response.failureKind ? { failureKind: response.failureKind } : {}),
|
|
});
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && req.url === "/bridge") {
|
|
const body = await readBody(req);
|
|
let rpc: {
|
|
jsonrpc: string;
|
|
id?: string | number;
|
|
method?: string;
|
|
params?: BridgeRpcParams;
|
|
};
|
|
try {
|
|
rpc = JSON.parse(body);
|
|
} catch {
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
return;
|
|
}
|
|
|
|
if (rpc.method === "message.send") {
|
|
if (!agentRuntime) {
|
|
res.writeHead(503);
|
|
res.end(
|
|
JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: rpc.id,
|
|
error: {
|
|
code: -32000,
|
|
message: "Agent runtime not ready",
|
|
},
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
const response = await agentRuntime.processMessage(rpc.params ?? {});
|
|
res.writeHead(200);
|
|
res.end(
|
|
JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: rpc.id,
|
|
result: {
|
|
text: response.text,
|
|
...(response.failureKind
|
|
? { failureKind: response.failureKind }
|
|
: {}),
|
|
metadata: {
|
|
timestamp: Date.now(),
|
|
...(response.failureKind
|
|
? { failureKind: response.failureKind }
|
|
: {}),
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (rpc.method === "status.get") {
|
|
res.writeHead(200);
|
|
res.end(
|
|
JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: rpc.id,
|
|
result: {
|
|
status: agentRuntime ? "running" : "initializing",
|
|
uptime: process.uptime(),
|
|
memoriesCount: state.memories.length,
|
|
startedAt: state.startedAt,
|
|
},
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (rpc.method === "heartbeat") {
|
|
res.writeHead(200);
|
|
res.end(
|
|
JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
method: "heartbeat.ack",
|
|
params: { timestamp: Date.now() },
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
res.writeHead(200);
|
|
res.end(
|
|
JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: rpc.id,
|
|
error: {
|
|
code: -32601,
|
|
message: `Method not found: ${rpc.method}`,
|
|
},
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404);
|
|
res.end(JSON.stringify({ error: "Not Found" }));
|
|
});
|
|
|
|
const bridgeBindAddress = bridgeSecretGenerated ? "127.0.0.1" : "0.0.0.0";
|
|
bridgeServer.listen(BRIDGE_PORT, bridgeBindAddress, () => {
|
|
logger.info(
|
|
`Bridge server listening on ${bridgeBindAddress}:${BRIDGE_PORT}`,
|
|
);
|
|
if (bridgeSecretGenerated) {
|
|
logger.warn(
|
|
"CRITICAL: No BRIDGE_SECRET configured — generated ephemeral secret and bound to 127.0.0.1 only",
|
|
);
|
|
logger.info(`Generated BRIDGE_SECRET: ${BRIDGE_SECRET}`);
|
|
}
|
|
});
|
|
|
|
// ─── Startup ──────────────────────────────────────────────────────────
|
|
|
|
function shutdown() {
|
|
logger.info("Shutting down...");
|
|
healthServer.close();
|
|
bridgeServer.close();
|
|
process.exit(0);
|
|
}
|
|
process.on("SIGTERM", shutdown);
|
|
process.on("SIGINT", shutdown);
|
|
|
|
// Crash guards. This file is bundled by esbuild for the cloud-agent image,
|
|
// which only installs @elizaos/core + @elizaos/plugin-sql, so we cannot import
|
|
// @elizaos/shared's installProcessCrashGuards here — the guards are inlined.
|
|
// A rejected background promise must never take down the container; a truly
|
|
// uncaught exception exits non-zero so the orchestrator (Docker
|
|
// `--restart unless-stopped` / K8s `restartPolicy: Always`) relaunches a clean
|
|
// container. Exit code matches @elizaos/shared RESTART_EXIT_CODE.
|
|
process.on("unhandledRejection", (reason) => {
|
|
logger.error("Unhandled promise rejection (non-fatal)", {
|
|
err:
|
|
reason instanceof Error
|
|
? (reason.stack ?? reason.message)
|
|
: String(reason),
|
|
});
|
|
});
|
|
process.on("uncaughtException", (error) => {
|
|
logger.error("Uncaught exception — exiting for orchestrator restart", {
|
|
err:
|
|
error instanceof Error ? (error.stack ?? error.message) : String(error),
|
|
});
|
|
process.exit(CLOUD_AGENT_RESTART_EXIT_CODE);
|
|
});
|
|
|
|
initRuntime()
|
|
.then(() => {
|
|
logger.info("Ready");
|
|
})
|
|
.catch((err) => {
|
|
logger.error("Runtime init failed", {
|
|
err: err instanceof Error ? err.message : String(err),
|
|
});
|
|
process.exit(1);
|
|
});
|
|
}
|