Files
agentmon/hooks/hermes/handler.ts
T
William Valentin 478c7529a7 feat(hooks): emit per-run token usage and duration on run.end
The stats layer reads usage/duration only from run.end, but neither
framework populated them, so tokens/cost/avg-duration were always 0.

- hermes: accumulate token usage across each run's api-result calls in
  session state and attach the summed usage plus a computed duration_ms
  (from a stored runStartedAt) onto run.end. metric.snapshot emission is
  unchanged, so there is no double counting.
- claude-code: store runStartedAt and use it as a duration_ms fallback at
  all run.end sites. Usage is unavailable from CC hook inputs.

Live verification: a real hermes run now reports duration_ms and
total_tokens on run.end; dashboard tokens_today/avg_duration_ms, both
previously 0, now populate. cost_today stays 0 (no provider emits cost
through the hooks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:16:23 -07:00

435 lines
12 KiB
JavaScript

#!/usr/bin/env node
import { randomUUID } from 'node:crypto';
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { homedir, hostname } from 'node:os';
import { join } from 'node:path';
import {
type Dict,
isRecord,
pickString,
pickNumber,
truncate,
buildEnvelope,
createTransport,
readStdin,
} from '../shared/lib';
const INGEST_URL = process.env.AGENTMON_INGEST_URL || 'http://localhost:8080';
const FRAMEWORK = process.env.AGENTMON_FRAMEWORK || 'hermes';
const HOST = process.env.AGENTMON_HOST || hostname();
const { enqueue, flush } = createTransport(INGEST_URL);
interface SessionState {
sessionStarted?: boolean;
runId?: string;
runStartedAt?: number;
runUsage?: Dict;
spans: { [key: string]: string };
}
// Token fields are reported per LLM call (api-result). A single run (user turn)
// can span several calls, so we sum them into a per-run total that rides along
// on run.end — the location the stats layer reads usage from.
const USAGE_TOKEN_FIELDS = [
'input_tokens',
'output_tokens',
'total_tokens',
'cache_read_tokens',
'cache_write_tokens',
'reasoning_tokens',
'total_cost',
];
function accumulateUsage(into: Dict, usage: Dict | undefined): Dict {
if (!usage) {
return into;
}
for (const key of USAGE_TOKEN_FIELDS) {
const v = pickNumber(usage[key]);
if (v !== undefined) {
into[key] = (pickNumber(into[key]) ?? 0) + v;
}
}
return into;
}
function runUsagePayload(state: SessionState): Dict | undefined {
return state.runUsage && Object.keys(state.runUsage).length > 0 ? state.runUsage : undefined;
}
const STATE_DIR = join(homedir(), '.agentmon-state', 'hermes');
function ensureStateDir() {
try {
mkdirSync(STATE_DIR, { recursive: true });
} catch {
// Hooks should never fail the agent because telemetry state is unavailable.
}
}
function loadState(sessionKey: string): SessionState {
try {
const raw = readFileSync(join(STATE_DIR, sessionKey + '.json'), 'utf8');
const state = JSON.parse(raw) as SessionState;
return { spans: {}, ...state };
} catch {
return { spans: {} };
}
}
function saveState(sessionKey: string | undefined, state: SessionState) {
if (!sessionKey) {
return;
}
ensureStateDir();
try {
writeFileSync(join(STATE_DIR, sessionKey + '.json'), JSON.stringify(state), 'utf8');
} catch {
// Best effort only.
}
}
function clearState(sessionKey: string | undefined) {
if (!sessionKey) {
return;
}
try {
unlinkSync(join(STATE_DIR, sessionKey + '.json'));
} catch {
// Best effort only.
}
}
function getExtra(input: Dict): Dict {
return isRecord(input.extra) ? input.extra : {};
}
function getSessionKey(input: Dict): string | undefined {
const extra = getExtra(input);
return pickString(
input.session_id,
input.sessionId,
input.sessionID,
input.session,
extra.session_id,
extra.sessionId,
extra.sessionID,
extra.parent_session_id,
extra.task_id,
);
}
function getToolCallId(input: Dict): string | undefined {
const extra = getExtra(input);
return pickString(input.tool_call_id, extra.tool_call_id, extra.call_id, extra.id);
}
function getToolName(input: Dict): string {
const extra = getExtra(input);
return pickString(input.tool_name, input.tool, input.name, extra.tool_name, extra.tool, extra.name) || 'unknown';
}
function getUsage(input: Dict): Dict | undefined {
const extra = getExtra(input);
const usage = isRecord(input.usage) ? input.usage :
isRecord(extra.usage) ? extra.usage :
undefined;
if (!usage) return undefined;
const result: Dict = {};
if (usage.input_tokens !== undefined) result.input_tokens = usage.input_tokens;
if (usage.prompt_tokens !== undefined) result.input_tokens = usage.prompt_tokens;
if (usage.output_tokens !== undefined) result.output_tokens = usage.output_tokens;
if (usage.completion_tokens !== undefined) result.output_tokens = usage.completion_tokens;
if (usage.cache_read_tokens !== undefined) result.cache_read_tokens = usage.cache_read_tokens;
if (usage.cache_write_tokens !== undefined) result.cache_write_tokens = usage.cache_write_tokens;
if (usage.cache_creation_tokens !== undefined) result.cache_write_tokens = usage.cache_creation_tokens;
if (usage.reasoning_tokens !== undefined) result.reasoning_tokens = usage.reasoning_tokens;
if (usage.total_tokens !== undefined) result.total_tokens = usage.total_tokens;
if (usage.total_cost !== undefined) result.total_cost = usage.total_cost;
if (usage.cost !== undefined) result.total_cost = usage.cost;
return Object.keys(result).length > 0 ? result : undefined;
}
function getModel(input: Dict): string | undefined {
const extra = getExtra(input);
return pickString(input.model, extra.model, extra.response_model);
}
function getPlatform(input: Dict): string | undefined {
const extra = getExtra(input);
return pickString(input.platform, extra.platform);
}
function getDuration(input: Dict): number | undefined {
const extra = getExtra(input);
return pickNumber(input.duration_ms, input.elapsed_ms, extra.duration_ms, extra.api_duration, extra.elapsed_ms);
}
function ensureSessionStarted(sessionKey: string | undefined, input: Dict, state: SessionState) {
if (!sessionKey || state.sessionStarted) {
return;
}
enqueue(buildEnvelope(FRAMEWORK, HOST, 'session.start', sessionKey, {
attributes: {
synthetic: true,
recovered_from: 'hermes-hook',
cwd: pickString(input.cwd),
platform: getPlatform(input),
model: getModel(input),
},
}));
state.sessionStarted = true;
saveState(sessionKey, state);
}
async function handleSessionStart(input: Dict) {
const sessionKey = getSessionKey(input) || randomUUID();
const state = loadState(sessionKey);
if (!state.sessionStarted) {
enqueue(buildEnvelope(FRAMEWORK, HOST, 'session.start', sessionKey, {
attributes: {
cwd: pickString(input.cwd),
platform: getPlatform(input),
model: getModel(input),
},
}));
state.sessionStarted = true;
saveState(sessionKey, state);
}
await flush();
}
async function handleRunStart(input: Dict) {
const sessionKey = getSessionKey(input) || randomUUID();
const state = loadState(sessionKey);
ensureSessionStarted(sessionKey, input, state);
if (state.runId) {
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.end', sessionKey, {
runId: state.runId,
payload: {
status: 'success',
duration_ms: state.runStartedAt ? Date.now() - state.runStartedAt : undefined,
...(runUsagePayload(state) && { usage: runUsagePayload(state) }),
},
}));
}
const runId = randomUUID();
state.runId = runId;
state.runStartedAt = Date.now();
state.runUsage = {};
saveState(sessionKey, state);
const extra = getExtra(input);
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.start', sessionKey, {
runId,
attributes: {
is_first_turn: extra.is_first_turn,
platform: getPlatform(input),
model: getModel(input),
},
payload: {
prompt_preview: truncate(extra.user_message ?? input.user_message, 200),
},
}));
await flush();
}
async function handleRunEnd(input: Dict) {
const sessionKey = getSessionKey(input);
if (!sessionKey) {
await flush();
return;
}
const state = loadState(sessionKey);
ensureSessionStarted(sessionKey, input, state);
if (state.runId) {
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.end', sessionKey, {
runId: state.runId,
payload: {
status: 'success',
duration_ms: getDuration(input) ?? (state.runStartedAt ? Date.now() - state.runStartedAt : undefined),
model: getModel(input),
response_preview: truncate(getExtra(input).assistant_response, 500),
...(runUsagePayload(state) && { usage: runUsagePayload(state) }),
},
}));
state.runId = undefined;
state.runStartedAt = undefined;
state.runUsage = {};
saveState(sessionKey, state);
}
await flush();
}
async function handleToolStart(input: Dict) {
const sessionKey = getSessionKey(input);
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
ensureSessionStarted(sessionKey, input, state);
const toolName = getToolName(input);
const toolCallId = getToolCallId(input);
const spanKey = toolCallId || toolName;
const spanId = randomUUID();
state.spans[spanKey] = spanId;
saveState(sessionKey, state);
enqueue(buildEnvelope(FRAMEWORK, HOST, 'span.start', sessionKey, {
runId: state.runId,
spanId,
attributes: {
span_kind: 'tool',
name: toolName,
tool_call_id: toolCallId,
},
payload: {
input: truncate(input.tool_input ?? getExtra(input).args, 500),
},
}));
await flush();
}
async function handleToolEnd(input: Dict) {
const sessionKey = getSessionKey(input);
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
const toolName = getToolName(input);
const toolCallId = getToolCallId(input);
const spanKey = toolCallId || toolName;
const spanId = state.spans[spanKey];
const extra = getExtra(input);
const result = extra.result ?? input.result;
const resultRecord = isRecord(result) ? result : {};
const success = extra.error === undefined && resultRecord.error === undefined;
enqueue(buildEnvelope(FRAMEWORK, HOST, 'span.end', sessionKey, {
runId: state.runId,
spanId,
attributes: {
span_kind: 'tool',
name: toolName,
tool_call_id: toolCallId,
},
payload: {
status: success ? 'success' : 'error',
result_preview: truncate(resultRecord.output ?? resultRecord.error ?? extra.error ?? result, 500),
duration_ms: getDuration(input),
},
}));
delete state.spans[spanKey];
saveState(sessionKey, state);
await flush();
}
async function handleAPIResult(input: Dict) {
const sessionKey = getSessionKey(input);
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
const usage = getUsage(input);
if (!usage) {
await flush();
return;
}
if (sessionKey) {
state.runUsage = accumulateUsage(state.runUsage || {}, usage);
saveState(sessionKey, state);
}
enqueue(buildEnvelope(FRAMEWORK, HOST, 'metric.snapshot', sessionKey, {
runId: state.runId,
payload: {
metrics: {
usage,
model: getModel(input),
provider: pickString(getExtra(input).provider),
duration_ms: getDuration(input),
},
},
}));
await flush();
}
async function handleSessionEnd(input: Dict) {
const sessionKey = getSessionKey(input);
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
if (state.runId) {
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.end', sessionKey, {
runId: state.runId,
payload: {
status: input.interrupted || getExtra(input).interrupted ? 'interrupted' : 'success',
duration_ms: state.runStartedAt ? Date.now() - state.runStartedAt : undefined,
model: getModel(input),
...(runUsagePayload(state) && { usage: runUsagePayload(state) }),
},
}));
}
enqueue(buildEnvelope(FRAMEWORK, HOST, 'session.end', sessionKey, {
payload: {
model: getModel(input),
completed: getExtra(input).completed,
interrupted: getExtra(input).interrupted,
},
}));
clearState(sessionKey);
await flush();
}
const handler = async () => {
const hookType = process.argv[2] || 'unknown';
let input: Dict = {};
try {
const stdin = await readStdin();
if (stdin) {
input = JSON.parse(stdin);
}
} catch {
input = {};
}
try {
switch (hookType) {
case 'session-start':
case 'start':
await handleSessionStart(input);
break;
case 'run-start':
case 'pre-llm':
await handleRunStart(input);
break;
case 'run-end':
case 'post-llm':
await handleRunEnd(input);
break;
case 'tool-start':
await handleToolStart(input);
break;
case 'tool-end':
await handleToolEnd(input);
break;
case 'api-result':
case 'post-api':
await handleAPIResult(input);
break;
case 'session-end':
case 'stop':
await handleSessionEnd(input);
break;
default:
console.debug(`[agentmon] unknown hermes hook type: ${hookType}`);
}
} catch (err) {
console.debug('[agentmon] hermes handler error:', err);
}
};
handler();