/** * Prompt auto-discovery (#55 Thread 2, box 126). * * Records a content-hash "fingerprint" for the stable, reusable part of each * ingested llm_call, so the prompt-management UI/API (GET /api/prompts/fingerprints) * can surface which prompts an agent actually runs — without anyone registering a * template by hand. Fingerprints live in a SIDE table (prompt_fingerprints); they * never touch the tamper-evident event chain. */ import { computePromptHash, type PromptStore } from '../db/prompt-store.js'; import type { AgentLensEvent } from '@agentkitai/agentlens-core'; import { createLogger } from './logger.js'; const log = createLogger('PromptFingerprint'); /** Coerce message content (a plain string OR a multimodal block array) to text. */ function contentToText(content: unknown): string { if (typeof content === 'string') return content; if (Array.isArray(content)) { return content .map((b) => { const block = b as { text?: unknown }; return typeof block?.text === 'string' ? block.text : ''; }) .filter(Boolean) .join('\n'); } return ''; } /** * The stable, cacheable part of an llm_call worth fingerprinting: the system * prompt (exactly what Anthropic prompt-caching caches), falling back to a leading * system message. Per-call user content is intentionally excluded so the hash is * stable across calls of the same template. */ function templatePrompt(payload: unknown): string | null { const p = payload as { systemPrompt?: unknown; messages?: unknown }; if (typeof p?.systemPrompt === 'string' && p.systemPrompt.trim()) return p.systemPrompt; const msgs = Array.isArray(p?.messages) ? p.messages : []; for (const m of msgs) { const mm = m as { role?: unknown; content?: unknown }; if (mm?.role === 'system') { const text = contentToText(mm.content); if (text.trim()) return text; } } return null; } /** * Record prompt fingerprints for any llm_call events just ingested. Best-effort: * never throws (a fingerprint failure must not break ingest), and no-ops when * prompt management is unavailable (promptStore === null, e.g. Postgres-only * deployments where the SQLite-backed PromptStore isn't mounted). */ export async function recordPromptFingerprints( promptStore: PromptStore | null, events: AgentLensEvent[], ): Promise { if (!promptStore) return; // Dedup within the batch so a burst of calls sharing one system prompt is a // single upsert (carrying the occurrence count) rather than N writes — bounds // work on the ingest path while keeping call_count accurate. // ponytail: still a sync per-distinct-prompt sqlite write on ingest; move to a // background worker if prompt-heavy throughput ever dominates ingest latency. const seen = new Map(); for (const ev of events) { if (ev.eventType !== 'llm_call') continue; const text = templatePrompt(ev.payload); if (!text) continue; const hash = computePromptHash(text); const key = `${ev.tenantId}${ev.agentId}${hash}`; const existing = seen.get(key); if (existing) existing.count++; else seen.set(key, { hash, tenantId: ev.tenantId, agentId: ev.agentId, sample: text, count: 1 }); } let failures = 0; for (const f of seen.values()) { try { await promptStore.upsertFingerprint(f.hash, f.tenantId, f.agentId, f.sample, f.count); } catch { failures++; } } if (failures > 0) { log.debug(`prompt fingerprint upsert failed for ${failures}/${seen.size} prompt(s); ingest unaffected`); } }