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
This commit is contained in:
Nexus Dev 2026-04-02 17:07:53 +00:00
parent f0b568ddd6
commit 39327b7660
3 changed files with 111 additions and 1 deletions

View file

@ -59,6 +59,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;
@ -1905,6 +1906,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) {

View file

@ -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<OllamaModel[]> {
}
}
export async function getOllamaMemoryUsage(modelName: string | null): Promise<number | null> {
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<string, number> = {
best: 4,
reasoning: 3,

View file

@ -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 (
<div className="rounded-lg border border-border p-4 space-y-3">
<h3 className="text-sm font-medium flex items-center gap-2">
Hermes Runtime
</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-xs text-muted-foreground">Model</p>
<p className="text-sm font-mono">{hermesModel ?? "Not set"}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Native Skills</p>
<p className="text-sm">{nativeSkillCount}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Memory (VRAM)</p>
<p className="text-sm">
{hermesMemoryBytes != null ? formatBytes(hermesMemoryBytes) : "Not loaded"}
</p>
</div>
</div>
</div>
);
}
/* ---- Agent Overview (main single-page view) ---- */
function AgentOverview({
@ -1201,6 +1247,11 @@ function AgentOverview({
</ChartCard>
</div>
{/* Hermes Runtime Info */}
{agent.adapterType === "hermes_local" && runtimeState && (
<HermesRuntimeCard runtimeState={runtimeState} agentId={agentId} />
)}
{/* Recent Issues */}
<div className="space-y-3">
<div className="flex items-center justify-between">