6 phases, 13 plans, 21 requirements. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
258 lines
12 KiB
Markdown
258 lines
12 KiB
Markdown
---
|
|
phase: 28-ollama-integration
|
|
plan: 03
|
|
type: execute
|
|
wave: 2
|
|
depends_on: [28-01]
|
|
files_modified:
|
|
- server/src/services/heartbeat.ts
|
|
- ui/src/pages/AgentDetail.tsx
|
|
autonomous: true
|
|
requirements: [HERM-06, HERM-07]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "After a Hermes heartbeat completes, stateJson contains hermesModel and hermesNativeSkillCount"
|
|
- "Dashboard AgentOverview shows model name and native skill count for Hermes agents"
|
|
- "Memory usage from /api/ps is shown when Ollama model is actively loaded"
|
|
- "Cost tracking shows $0.00 for Ollama-based runs (correct behavior, no code change needed)"
|
|
- "stateJson merge uses spread/jsonb concat, never overwrites existing fields"
|
|
artifacts:
|
|
- path: "server/src/services/heartbeat.ts"
|
|
provides: "Hermes stateJson merge in updateRuntimeState"
|
|
contains: "hermesModel"
|
|
- path: "ui/src/pages/AgentDetail.tsx"
|
|
provides: "HermesRuntimeCard component in AgentOverview"
|
|
contains: "HermesRuntimeCard"
|
|
key_links:
|
|
- from: "server/src/services/heartbeat.ts"
|
|
to: "server/src/services/ollama.ts"
|
|
via: "import for /api/ps memory query"
|
|
pattern: "ollama"
|
|
- from: "ui/src/pages/AgentDetail.tsx"
|
|
to: "agentRuntimeState.stateJson"
|
|
via: "runtimeState.stateJson?.hermesModel"
|
|
pattern: "hermesModel"
|
|
---
|
|
|
|
<objective>
|
|
Wire Hermes runtime data into stateJson after heartbeat runs and render it in the dashboard.
|
|
|
|
Purpose: Users see Hermes-specific information (model name, native skill count, memory usage) on the agent dashboard. Cost tracking for Ollama-based runs correctly shows $0.00 (already wired, just needs verification).
|
|
Output: stateJson populated after Hermes runs, HermesRuntimeCard visible in AgentOverview.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/28-ollama-integration/28-RESEARCH.md
|
|
@.planning/phases/28-ollama-integration/28-01-SUMMARY.md
|
|
|
|
@server/src/services/heartbeat.ts (updateRuntimeState around line 1846)
|
|
@server/src/services/ollama.ts (from Plan 01)
|
|
@ui/src/pages/AgentDetail.tsx (AgentOverview around line 1168)
|
|
|
|
<interfaces>
|
|
<!-- From heartbeat.ts updateRuntimeState signature -->
|
|
async function updateRuntimeState(
|
|
agent: typeof agents.$inferSelect,
|
|
run: typeof heartbeatRuns.$inferSelect,
|
|
result: AdapterExecutionResult,
|
|
session: { legacySessionId: string | null },
|
|
normalizedUsage?: UsageTotals | null,
|
|
): void
|
|
|
|
<!-- AdapterExecutionResult has: model?: string, usage?, costUsd?, resultJson? -->
|
|
|
|
<!-- stateJson patch shape for Hermes -->
|
|
{
|
|
hermesModel: string; // from result.model
|
|
hermesNativeSkillCount: number; // from skill registry query or UI-derived
|
|
hermesMemoryBytes: number | null; // from /api/ps size_vram
|
|
}
|
|
|
|
<!-- AgentOverview props -->
|
|
agent: AgentDetailRecord (has adapterType)
|
|
runtimeState?: AgentRuntimeState (has stateJson: Record<string, unknown>)
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Merge Hermes runtime data into stateJson after heartbeat</name>
|
|
<files>server/src/services/heartbeat.ts</files>
|
|
<read_first>
|
|
- server/src/services/heartbeat.ts (lines 1846-1900 for updateRuntimeState, lines 2690-2710 for where updateRuntimeState is called)
|
|
- server/src/services/ollama.ts (for import path — Plan 01 created this)
|
|
- .planning/phases/28-ollama-integration/28-RESEARCH.md (Pitfall 3 — stateJson merge, Pitfall 5 — /api/ps empty case, Pitfall 6 — cost tracking)
|
|
</read_first>
|
|
<action>
|
|
Modify `updateRuntimeState` in `server/src/services/heartbeat.ts` to merge Hermes-specific data into stateJson:
|
|
|
|
1. At the top of heartbeat.ts, add: `import { getOllamaMemoryUsage } from "../services/ollama.js"` (this function needs to be added to ollama.ts first — see step 3).
|
|
|
|
2. After the existing `db.update(agentRuntimeState).set(...)` call (around line 1879), add a conditional block:
|
|
```typescript
|
|
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));
|
|
}
|
|
```
|
|
|
|
3. Add `getOllamaMemoryUsage` function to `server/src/services/ollama.ts` (modify the file created in Plan 01):
|
|
- `export async function getOllamaMemoryUsage(modelName: string | null): Promise<number | null>`
|
|
- Fetch `${OLLAMA_BASE_URL}/api/ps` with 3s AbortController timeout
|
|
- Parse response as OllamaPsResponse
|
|
- Find the model matching `modelName` in the response.models array
|
|
- Return `size_vram` from the match, or null if no match / empty array / error
|
|
- On any error, return null (graceful degradation per Pitfall 5)
|
|
|
|
4. HERM-06 verification: The existing cost tracking code in updateRuntimeState already handles Hermes correctly:
|
|
- `result.costUsd` is undefined for local Ollama → `normalizeBilledCostCents(undefined, ...)` returns 0
|
|
- Token usage may or may not be emitted by Hermes → cost event created only if `hasTokenUsage`
|
|
- No code changes needed for HERM-06 — just verify the path works by reading the code.
|
|
|
|
NOTE: hermesNativeSkillCount is NOT stored in stateJson. Per RESEARCH Open Question 3, the UI will derive this from the existing `agentsApi.skills(agentId)` query (which already syncs Hermes native skills via skillRegistryService). This avoids cross-DB complexity in the heartbeat path.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /opt/nexus/server && npx tsc --noEmit 2>&1 | head -20</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- grep -q "hermesModel" server/src/services/heartbeat.ts
|
|
- grep -q "hermesMemoryBytes" server/src/services/heartbeat.ts
|
|
- grep -q "hermes_local" server/src/services/heartbeat.ts
|
|
- grep -q "COALESCE" server/src/services/heartbeat.ts
|
|
- grep -q "getOllamaMemoryUsage" server/src/services/ollama.ts
|
|
- grep -q "api/ps" server/src/services/ollama.ts
|
|
</acceptance_criteria>
|
|
<done>After a Hermes heartbeat run, stateJson is updated with hermesModel and hermesMemoryBytes via jsonb merge. getOllamaMemoryUsage queries /api/ps gracefully. TypeScript compiles without errors.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create HermesRuntimeCard component in AgentOverview</name>
|
|
<files>ui/src/pages/AgentDetail.tsx</files>
|
|
<read_first>
|
|
- ui/src/pages/AgentDetail.tsx (lines 1168-1230 for AgentOverview structure, lines 1002-1010 for AgentOverview props passed)
|
|
- ui/src/pages/AgentDetail.tsx (lines 2362-2380 for AgentSkillsTab — to see how skills query works)
|
|
- .planning/phases/28-ollama-integration/28-RESEARCH.md (HERM-07 section)
|
|
</read_first>
|
|
<action>
|
|
Add a `HermesRuntimeCard` component to `AgentDetail.tsx` and render it in `AgentOverview`:
|
|
|
|
1. Create `HermesRuntimeCard` function component (define it near AgentOverview, around line 1160):
|
|
```typescript
|
|
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?.adapterEntries) return 0;
|
|
return skillsData.adapterEntries.filter((e: any) => 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>
|
|
);
|
|
}
|
|
```
|
|
|
|
2. In `AgentOverview` component (around line 1202, after the charts grid div), add:
|
|
```tsx
|
|
{agent.adapterType === "hermes_local" && runtimeState && (
|
|
<HermesRuntimeCard runtimeState={runtimeState} agentId={agentId} />
|
|
)}
|
|
```
|
|
|
|
3. Ensure imports: `useMemo` should already be imported. `agentsApi` should already be imported. `useQuery` should already be imported. If not, add the necessary imports.
|
|
|
|
4. Check the `AgentRuntimeState` type — stateJson should be typed as `Record<string, unknown>` or similar. If it's typed more narrowly, cast as needed.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /opt/nexus/ui && npx tsc --noEmit 2>&1 | head -20</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- grep -q "HermesRuntimeCard" ui/src/pages/AgentDetail.tsx
|
|
- grep -q "hermesModel" ui/src/pages/AgentDetail.tsx
|
|
- grep -q "hermesMemoryBytes" ui/src/pages/AgentDetail.tsx
|
|
- grep -q "Native Skills" ui/src/pages/AgentDetail.tsx
|
|
- grep -q "hermes_local.*runtimeState" ui/src/pages/AgentDetail.tsx
|
|
- grep -q "Hermes Runtime" ui/src/pages/AgentDetail.tsx
|
|
</acceptance_criteria>
|
|
<done>HermesRuntimeCard renders in AgentOverview for Hermes agents, showing model name, native skill count (from skills API), and memory usage (from stateJson). TypeScript compiles without errors.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `cd /opt/nexus/server && npx tsc --noEmit` — no type errors
|
|
- `cd /opt/nexus/ui && npx tsc --noEmit` — no type errors
|
|
- stateJson merge uses COALESCE + jsonb concat (not overwrite)
|
|
- HermesRuntimeCard gated by adapterType === "hermes_local"
|
|
- Cost tracking path verified (no changes needed per HERM-06)
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Hermes heartbeat runs populate stateJson with hermesModel and hermesMemoryBytes
|
|
- HermesRuntimeCard displays in AgentOverview for Hermes agents
|
|
- Native skill count derived from skills API (no cross-DB query in heartbeat)
|
|
- Memory usage shows "Not loaded" when Ollama model isn't active
|
|
- Cost tracking correctly shows $0.00 for Ollama runs (existing behavior, verified)
|
|
- All code compiles without TypeScript errors
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/28-ollama-integration/28-03-SUMMARY.md`
|
|
</output>
|