From 5266d2572767a35b60123fdd47a66a29460197ef Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Thu, 2 Apr 2026 17:07:53 +0000 Subject: [PATCH] feat(28-03): merge Hermes runtime data into stateJson and add HermesRuntimeCard - Add OllamaPsResponse interface and getOllamaMemoryUsage() to ollama.ts - Import getOllamaMemoryUsage in heartbeat.ts - Add hermes_local block in updateRuntimeState: COALESCE jsonb merge of hermesModel + hermesMemoryBytes - Add HermesRuntimeCard component in AgentDetail.tsx - Render HermesRuntimeCard in AgentOverview gated by adapterType === hermes_local - Native skill count derived from agentsApi.skills entries with originLabel === Hermes skill --- server/src/services/heartbeat.ts | 22 ++++++++++++++ server/src/services/ollama.ts | 39 +++++++++++++++++++++++- ui/src/pages/AgentDetail.tsx | 51 ++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index f50f09d8..761a1c01 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -57,6 +57,7 @@ import { resolveSessionCompactionPolicy, type SessionCompactionPolicy, } from "@paperclipai/adapter-utils"; +import { getOllamaMemoryUsage } from "./ollama.js"; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1; @@ -1896,6 +1897,27 @@ export function heartbeatService(db: Db) { occurredAt: new Date(), }); } + + if (agent.adapterType === "hermes_local") { + const hermesModel = result.model ?? null; + // Query Ollama /api/ps for memory usage (non-blocking, best-effort) + let hermesMemoryBytes: number | null = null; + try { + hermesMemoryBytes = await getOllamaMemoryUsage(hermesModel); + } catch { + // Ollama may not be running or model not loaded — acceptable + } + // Merge into stateJson using jsonb concat to avoid overwriting other fields + await db + .update(agentRuntimeState) + .set({ + stateJson: sql`COALESCE(${agentRuntimeState.stateJson}, '{}'::jsonb) || ${JSON.stringify({ + hermesModel, + hermesMemoryBytes, + })}::jsonb`, + }) + .where(eq(agentRuntimeState.agentId, agent.id)); + } } async function startNextQueuedRunForAgent(agentId: string) { diff --git a/server/src/services/ollama.ts b/server/src/services/ollama.ts index 08836474..584d1323 100644 --- a/server/src/services/ollama.ts +++ b/server/src/services/ollama.ts @@ -1,4 +1,3 @@ -import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import path from "node:path"; import fs from "node:fs"; @@ -41,6 +40,25 @@ interface OllamaTagsResponse { }>; } +interface OllamaPsResponse { + models: Array<{ + name: string; + model: string; + size: number; + digest: string; + details: { + parent_model: string; + format: string; + family: string; + families: string[]; + parameter_size: string; + quantization_level: string; + }; + expires_at: string; + size_vram: number; + }>; +} + interface CatalogVariant { name: string; ramGb: number; @@ -111,6 +129,25 @@ export async function listOllamaModels(): Promise { } } +export async function getOllamaMemoryUsage(modelName: string | null): Promise { + if (!modelName) return null; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS); + try { + const res = await fetch(`${OLLAMA_BASE_URL}/api/ps`, { + signal: controller.signal, + }); + if (!res.ok) return null; + const body = (await res.json()) as OllamaPsResponse; + const match = (body.models ?? []).find((m) => m.name === modelName || m.model === modelName); + return match?.size_vram ?? null; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + const QUALITY_RANK: Record = { best: 4, reasoning: 3, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 204f9b06..06e12b74 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1163,6 +1163,52 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin ); } +/* ---- Hermes Runtime Card ---- */ + +function HermesRuntimeCard({ runtimeState, agentId }: { runtimeState: AgentRuntimeState; agentId: string }) { + const hermesModel = runtimeState.stateJson?.hermesModel as string | undefined; + const hermesMemoryBytes = runtimeState.stateJson?.hermesMemoryBytes as number | null | undefined; + + // Derive native skill count from existing skills query + const { data: skillsData } = useQuery({ + queryKey: ["agents", agentId, "skills"], + queryFn: () => agentsApi.skills(agentId), + }); + const nativeSkillCount = useMemo(() => { + if (!skillsData?.entries) return 0; + return skillsData.entries.filter((e) => e.originLabel === "Hermes skill").length; + }, [skillsData]); + + const formatBytes = (bytes: number) => { + const gb = bytes / (1024 * 1024 * 1024); + return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / (1024 * 1024)).toFixed(0)} MB`; + }; + + return ( +
+

+ Hermes Runtime +

+
+
+

Model

+

{hermesModel ?? "Not set"}

+
+
+

Native Skills

+

{nativeSkillCount}

+
+
+

Memory (VRAM)

+

+ {hermesMemoryBytes != null ? formatBytes(hermesMemoryBytes) : "Not loaded"} +

+
+
+
+ ); +} + /* ---- Agent Overview (main single-page view) ---- */ function AgentOverview({ @@ -1201,6 +1247,11 @@ function AgentOverview({ + {/* Hermes Runtime Info */} + {agent.adapterType === "hermes_local" && runtimeState && ( + + )} + {/* Recent Issues */}