478c7529a7
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>
435 lines
12 KiB
JavaScript
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();
|