/** * LLM-driven L2 policy induction. * * Given a candidate bucket (≥ N distinct episodes' worth of evidence traces * sharing a PatternSignature), call the `l2.induction` prompt and build a * `PolicyRow` draft. If the draft passes cheap validation we persist it as a * new row with `status = 'candidate'`; gain updates can later promote it. * * Pure induction logic — no candidate-pool or events writes. Callers handle * that. */ import { ERROR_CODES, MemosError } from "../../../agent-contract/errors.js"; import { detectDominantLanguage, languageSteeringLine, } from "../../llm/prompts/index.js"; import { L2_INDUCTION_PROMPT } from "../../llm/prompts/l2-induction.js"; import type { LlmClient } from "../../llm/index.js"; import type { Logger } from "../../logger/types.js"; import { sanitizeDerivedMarkdown, sanitizeDerivedMarkdownList, sanitizeDerivedText } from "../../safety/content.js"; import type { EmbeddingVector, EpisodeId, PolicyId, PolicyMetadata, PolicyRow, TraceId, TraceRow, } from "../../types.js"; import { ids } from "../../id.js"; import { centroid } from "./similarity.js"; import type { InductionDraft, InductionDraftResult } from "./types.js"; export interface InduceInput { /** Traces that back the induction (one from each episode; may contain duplicates). */ evidenceTraces: readonly TraceRow[]; /** Episode ids these traces came from — must be the distinct set. */ episodeIds: readonly EpisodeId[]; /** Human-readable signature for the bucket — appears in prompts. */ signatureLabel: string; charCap: number; /** * Episode that triggered this induction run (i.e. the episode whose * trace just landed and re-fired runL2). Forwarded to the LLM call so * the resulting `system_model_status` audit row can be grouped with * the rest of that episode's pipeline activity in the Logs viewer. */ triggerEpisodeId?: EpisodeId; } export interface InduceDeps { llm: LlmClient | null; log: Logger; /** When provided, run after JSON parse but before we accept the draft. */ validate?: (d: InductionDraft) => void; } // ─── Public API ───────────────────────────────────────────────────────────── /** * Run the induction LLM call and validate the draft. Returns a tagged union * — callers can decide whether to persist. */ export async function induceDraft( input: InduceInput, deps: InduceDeps, ): Promise { const { llm, log } = deps; if (!llm) { log.warn("l2.induce.llm_unavailable", { signatureLabel: input.signatureLabel, evidenceCount: input.evidenceTraces.length, fallback: "skipped", }); return { ok: false, reason: "llm_disabled" }; } const userPayload = packTraces(input.evidenceTraces, input.charCap, input.signatureLabel); // Match the induced policy's title/trigger/action/rationale to the // dominant language of the evidence bucket — Chinese users expect // their own L2 memories in 中文, English users expect English. const evidenceLang = detectDominantLanguage( input.evidenceTraces.flatMap((t) => [t.userText, t.agentText, t.reflection]), ); try { const rsp = await llm.completeJson<{ title: unknown; trigger: unknown; action?: unknown; procedure?: unknown; verification?: unknown; rationale?: unknown; caveats?: unknown; confidence?: unknown; support_trace_ids?: unknown; }>( [ { role: "system", content: L2_INDUCTION_PROMPT.system }, { role: "system", content: languageSteeringLine(evidenceLang) }, { role: "user", content: userPayload }, ], { op: `l2.${L2_INDUCTION_PROMPT.id}.v${L2_INDUCTION_PROMPT.version}`, phase: "l2", episodeId: input.triggerEpisodeId ?? input.episodeIds[input.episodeIds.length - 1], temperature: 0.1, malformedRetries: 1, schemaHint: `{"title":"...","trigger":"...","procedure":"...","verification":"...","rationale":"...","caveats":["..."],"confidence":0..1,"support_trace_ids":["tr_..."]}`, validate: (v) => { const o = v as Record; for (const k of ["title", "trigger"]) { if (typeof o[k] !== "string" || !(o[k] as string).trim()) { throw new MemosError( ERROR_CODES.LLM_OUTPUT_MALFORMED, `l2.induction: '${k}' must be a non-empty string`, { got: o[k] }, ); } } if ( typeof o.procedure !== "string" && typeof o.action !== "string" ) { throw new MemosError( ERROR_CODES.LLM_OUTPUT_MALFORMED, `l2.induction: 'procedure' (or legacy 'action') must be a string`, { got: o.procedure ?? o.action }, ); } }, }, ); const draft = normaliseDraft(rsp.value, input.evidenceTraces.map((t) => t.id)); if (deps.validate) deps.validate(draft); return { ok: true, draft }; } catch (err) { log.warn("induce.llm_failed", { signature: input.signatureLabel, err: err instanceof Error ? err.message : String(err), }); return { ok: false, reason: "llm_failed", detail: err instanceof Error ? err.message : String(err) }; } } /** * Convert a validated draft into a ready-to-persist `PolicyRow`. * Gain/support are zero-initialised — the gain step fills them in. */ export function buildPolicyRow(args: { draft: InductionDraft; episodeIds: readonly EpisodeId[]; evidenceTraces: readonly TraceRow[]; inducedBy: string; // prompt id + version now?: number; id?: PolicyId; sourceSignature?: string; }): PolicyRow { const now = args.now ?? Date.now(); const vec = centroid(args.evidenceTraces.map((t) => t.vecSummary ?? t.vecAction ?? null)); return { id: (args.id ?? (ids.policy() as PolicyId)), title: args.draft.title.slice(0, 120), trigger: args.draft.trigger, procedure: args.draft.procedure, verification: args.draft.verification, boundary: args.draft.boundary, support: 0, gain: 0, status: "candidate", sourceEpisodeIds: Array.from(new Set(args.episodeIds)), sourceTraceIds: Array.from(new Set(args.evidenceTraces.map((trace) => trace.id))), inducedBy: args.inducedBy, // Fresh policy starts without learned guidance — populated by the // decision-repair pipeline as user feedback / failure bursts arrive. decisionGuidance: { preference: [], antiPattern: [] }, vec: vec as EmbeddingVector | null, createdAt: now, updatedAt: now, metadata: derivePolicyMetadata(args.evidenceTraces, args.sourceSignature), }; } function derivePolicyMetadata( traces: readonly TraceRow[], sourceSignature?: string, ): PolicyMetadata { const domainTags = uniqueStrings(traces.flatMap((t) => t.tags ?? [])); const toolNames = uniqueStrings( traces.flatMap((t) => (t.toolCalls ?? []).map((c) => c.name ?? "")), ); const errorCodes = uniqueStrings( traces.flatMap((t) => { const text = [ t.agentText, t.reflection ?? "", ...(t.toolCalls ?? []).map((c) => typeof c.output === "string" ? c.output : "", ), ].join(" "); return Array.from( text.matchAll(/\b[A-Z][A-Z0-9]{2,}_[A-Z0-9_]+\b/g), (m) => m[0], ); }), ); let zh = 0; let en = 0; for (const t of traces) { for (const s of [t.userText, t.agentText, t.reflection ?? ""]) { for (const ch of s) { const code = ch.charCodeAt(0); if (code >= 0x4e00 && code <= 0x9fff) zh++; else if ( (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a) ) en++; } } } const total = zh + en; const language = total === 0 ? "unknown" : zh / total >= 0.7 ? "zh" : en / total >= 0.7 ? "en" : "mixed"; return { version: 1, language, domainTags, toolNames, errorCodes, ...(sourceSignature ? { sourceSignature } : {}), }; } function uniqueStrings(values: readonly string[]): string[] { return Array.from( new Set(values.map((v) => v.trim().toLowerCase()).filter(Boolean)), ).slice(0, 32); } // ─── helpers ──────────────────────────────────────────────────────────────── function packTraces( traces: readonly TraceRow[], charCap: number, label: string, ): string { const header = `PATTERN_SIGNATURE: ${label}\nTRACES (one per block):`; const blocks: string[] = []; let budget = Math.max(400, charCap - header.length - 100); for (const t of traces) { const block = [ `---`, `id: ${t.id}`, `episode: ${t.episodeId}`, `tags: ${(t.tags ?? []).join(",") || "-"}`, `user: ${truncate(t.userText, 200)}`, `agent: ${truncate(t.agentText, 300)}`, `tools: ${formatTools(t.toolCalls)}`, `reflection: ${truncate(t.reflection ?? "-", 300)}`, `V: ${t.value.toFixed(2)} alpha: ${t.alpha.toFixed(2)}`, ].join("\n"); if (block.length > budget) { blocks.push(block.slice(0, budget)); break; } blocks.push(block); budget -= block.length; } return `${header}\n${blocks.join("\n")}`; } function formatTools(calls: TraceRow["toolCalls"] | undefined): string { if (!calls || calls.length === 0) return "-"; return calls .slice(0, 3) .map((c) => { const out = typeof c.output === "string" ? truncate(c.output, 80) : JSON.stringify(c.output ?? "").slice(0, 80); return `${c.name ?? "?"}(${truncate(JSON.stringify(c.input ?? ""), 40)}) → ${out}`; }) .join("; "); } function truncate(s: string, n: number): string { if (!s) return ""; if (s.length <= n) return s; return s.slice(0, n - 1) + "…"; } function normaliseDraft(value: Record, traceIds: readonly TraceId[]): InductionDraft { const procedure = typeof value.procedure === "string" ? (value.procedure as string) : typeof value.action === "string" ? (value.action as string) : ""; const caveats = Array.isArray(value.caveats) ? sanitizeDerivedMarkdownList((value.caveats as unknown[]).filter((c): c is string => typeof c === "string")) : []; const confidence = typeof value.confidence === "number" ? value.confidence : 0.5; const supportTraceIds = Array.isArray(value.support_trace_ids) ? (value.support_trace_ids as unknown[]).filter((x): x is string => typeof x === "string") : []; return { title: sanitizeDerivedText(value.title), trigger: sanitizeDerivedMarkdown(value.trigger), procedure: sanitizeDerivedMarkdown(procedure), verification: typeof value.verification === "string" ? sanitizeDerivedMarkdown(value.verification) : "", boundary: typeof value.boundary === "string" ? sanitizeDerivedMarkdown(value.boundary) : "", rationale: typeof value.rationale === "string" ? sanitizeDerivedMarkdown(value.rationale) : "", caveats, confidence: Math.max(0, Math.min(1, confidence)), supportTraceIds: supportTraceIds.length > 0 ? (supportTraceIds as TraceId[]) : Array.from(traceIds), }; }