/** * V7 §2.4.6 — gather evidence for a decision-repair synthesis. * * Given a context hash + sessionId (and, optionally, an episodeId), we need * two sets of traces: * * - **HIGH_VALUE**: recent traces in the same session with value > 0 whose * agentText overlaps with the failing context. These drive the * `preference` field. * - **LOW_VALUE**: recent traces in the same session with value ≤ 0 (or * explicit failure markers). These drive the `anti_pattern` field. * * The gather is a cheap SQL scan — no vector math — because the traces * we're interested in are always very recent. The orchestrator caps the * evidence at `evidenceLimit` per class. */ import type { Logger } from "../logger/types.js"; import type { Repos } from "../storage/repos/index.js"; import type { SessionId, TraceRow } from "../types.js"; import type { FeedbackConfig } from "./types.js"; export interface EvidenceInput { sessionId: SessionId; /** Optional token to match inside trace agentText/userText/reflection. */ keyword?: string; limit?: number; } export interface EvidenceResult { highValue: TraceRow[]; lowValue: TraceRow[]; } export interface EvidenceDeps { repos: Repos; config: FeedbackConfig; log: Logger; } export function gatherRepairEvidence( input: EvidenceInput, deps: EvidenceDeps, ): EvidenceResult { const cap = input.limit ?? deps.config.evidenceLimit; const needle = input.keyword?.toLowerCase().trim() ?? ""; const minLowValueThreshold = deps.config.minLowValueThreshold; // Pull a generous recent batch and split by value sign. Limiting at the // SQL layer is fine because the caller passes a small `limit`. const recent = deps.repos.traces.list({ sessionId: input.sessionId, limit: Math.max(cap * 6, 24), }); if (recent.length === 0) { return { highValue: [], lowValue: [] }; } // Two-pass: first try with the keyword filter; if BOTH lists come back // empty, relax and rescan without the filter. This keeps the happy path // relevance-aware while still producing useful evidence when the // keyword is a normalized tool ID ("pip.install") that doesn't appear // verbatim in the natural-language trace text. If only one side is // empty we keep the filtered result — the synthesizer is designed to // fall back to template output in that case without needing extra // context. const firstPass = partition(recent, cap, needle, minLowValueThreshold); const firstPassEmpty = firstPass.highValue.length === 0 && firstPass.lowValue.length === 0; if (!needle || !firstPassEmpty) { deps.log.debug("evidence.gathered", { sessionId: input.sessionId, highValue: firstPass.highValue.length, lowValue: firstPass.lowValue.length, hadKeyword: Boolean(needle), relaxed: false, }); return firstPass; } const relaxed = partition(recent, cap, "", minLowValueThreshold); deps.log.debug("evidence.gathered", { sessionId: input.sessionId, highValue: relaxed.highValue.length, lowValue: relaxed.lowValue.length, hadKeyword: Boolean(needle), relaxed: true, }); return relaxed; } function partition( traces: readonly TraceRow[], cap: number, needle: string, minLowValueThreshold: number, ): EvidenceResult { const highValue: TraceRow[] = []; const lowValue: TraceRow[] = []; for (const trace of traces) { if (needle && !traceContains(trace, needle)) continue; if (trace.value > 0) { if (highValue.length < cap) highValue.push(trace); } else if (trace.value < -minLowValueThreshold || isFailureLike(trace)) { if (lowValue.length < cap) lowValue.push(trace); } if (highValue.length >= cap && lowValue.length >= cap) break; } return { highValue, lowValue }; } function traceContains(trace: TraceRow, needle: string): boolean { const blob = `${trace.userText}\n${trace.agentText}\n${trace.reflection ?? ""}`.toLowerCase(); return blob.includes(needle); } function isFailureLike(trace: TraceRow): boolean { const blob = `${trace.agentText}\n${trace.reflection ?? ""}`.toLowerCase(); return ( /(error|failed|failure|exception|traceback|timeout|retry)/.test(blob) || trace.toolCalls.some((call) => Boolean(call.errorCode)) ); } /** * Truncate a trace trio to roughly `maxChars` preserving the tail (the * most recent lines) — the error messages we want to learn from usually * live at the end of the agentText. */ export function capTrace(trace: TraceRow, maxChars: number): TraceRow { if (maxChars <= 0) return trace; return { ...trace, userText: tail(trace.userText, maxChars), agentText: tail(trace.agentText, maxChars), reflection: trace.reflection ? tail(trace.reflection, maxChars) : trace.reflection, }; } function tail(s: string, n: number): string { if (s.length <= n) return s; return `...${s.slice(s.length - n)}`; }