diff --git a/.env.example b/.env.example index 08ebcea..284f267 100644 --- a/.env.example +++ b/.env.example @@ -76,6 +76,10 @@ DROPS_AGENT_MAX_PARALLEL_ROLES= DROPS_AGENT_MAX_ROLE_CALLS= DROPS_AGENT_MAX_REPAIR_ROUNDS= DROPS_AGENT_MAX_RUN_COST_USD= +# Activates the compact V3 system core for request-scoped agent runs. The legacy +# prompt remains the bounded rollback path and is never removed by this flag. +DROPS_AGENT_COMPACT_CORE_ENABLED= +DROPS_AGENT_LEGACY_CORE_FALLBACK= # Context Compiler uses the in-process/private Blob snapshot backend by default. # An embedding provider/model is optional and must use an already authorized model path. @@ -86,6 +90,8 @@ DROPS_CONTEXT_INDEX_SNAPSHOT_PREFIX= # Internal-only eval dashboard and benchmark runners. Use a distinct 32+ byte value. DROPS_EVALS_INTERNAL_ACCESS_SECRET= +# Enables operator-triggered full release evidence runs. Live model probes stay +# bounded to two verified measurements and persist digests/usage only. DROPS_EVALS_NIGHTLY_ENABLED= DROPS_EXPERIMENTS_ENABLED= diff --git a/app/api/internal/agent-evals/run/route.ts b/app/api/internal/agent-evals/run/route.ts index 47d4bb1..dee53db 100644 --- a/app/api/internal/agent-evals/run/route.ts +++ b/app/api/internal/agent-evals/run/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server.js"; import { z } from "zod"; import { agentEvalsAccessConfigured, + activateAgentV3Evidence, authorizeAgentEvals, benchmarkCasesForSuite, DEFAULT_BENCHMARK_CONFIGURATIONS, @@ -16,12 +17,14 @@ export const maxDuration = 300; const inputSchema = z.object({ suite: z.enum(["local-fast", "ci", "nightly", "release"]).default("local-fast"), + activateEvidence: z.boolean().default(false), }).strict(); export interface AgentEvalRunRouteDependencies { store?: DefaultAgentEvalStore; env?: NodeJS.ProcessEnv; execute?: typeof executeOfflineContractBenchmark; + activate?: typeof activateAgentV3Evidence; } export async function handleAgentEvalRun( @@ -43,20 +46,31 @@ export async function handleAgentEvalRun( } catch { return NextResponse.json({ code: "EVALS_INVALID_REQUEST", error: "A valid bounded evaluation request is required." }, { status: 400 }); } - if ((input.suite === "nightly" && env.DROPS_EVALS_NIGHTLY_ENABLED !== "1") || (input.suite === "release" && env.NODE_ENV !== "production" && env.DROPS_EVALS_NIGHTLY_ENABLED !== "1")) { - return NextResponse.json({ code: "EVALS_SUITE_DISABLED", error: `${input.suite} evaluations are disabled.` }, { status: 409 }); + const suite = input.activateEvidence ? "release" : input.suite; + if ((suite === "nightly" && env.DROPS_EVALS_NIGHTLY_ENABLED !== "1") || (suite === "release" && env.NODE_ENV !== "production" && env.DROPS_EVALS_NIGHTLY_ENABLED !== "1")) { + return NextResponse.json({ code: "EVALS_SUITE_DISABLED", error: `${suite} evaluations are disabled.` }, { status: 409 }); } try { + const store = dependencies.store ?? new DefaultAgentEvalStore(); const report = await runAgentBenchmark({ - suite: input.suite, - cases: benchmarkCasesForSuite(input.suite), + suite, + cases: benchmarkCasesForSuite(suite), configurations: DEFAULT_BENCHMARK_CONFIGURATIONS, execute: dependencies.execute ?? executeOfflineContractBenchmark, - concurrency: 3, + concurrency: input.activateEvidence ? 8 : 3, }); - await (dependencies.store ?? new DefaultAgentEvalStore()).writeReport(report); + await store.writeReport(report); + const snapshot = input.activateEvidence + ? await (dependencies.activate ?? activateAgentV3Evidence)(report) + : null; + if (snapshot) await store.writeEvidenceSnapshot(snapshot); return NextResponse.json( - { report, executionMode: "offline-contract-fixture" }, + { + report, + evidenceActivated: Boolean(snapshot), + snapshotId: snapshot?.snapshotId ?? null, + executionMode: snapshot ? "offline-contract-fixture+live-model-matrix" : "offline-contract-fixture", + }, { status: report.releaseGate.passed ? 200 : 422, headers: { "cache-control": "no-store" } }, ); } catch { diff --git a/app/api/internal/agent-evals/summary/route.ts b/app/api/internal/agent-evals/summary/route.ts index d7e3abf..f1e3099 100644 --- a/app/api/internal/agent-evals/summary/route.ts +++ b/app/api/internal/agent-evals/summary/route.ts @@ -28,11 +28,12 @@ export async function handleAgentEvalSummary( } try { const store = dependencies.store ?? new DefaultAgentEvalStore(); - const [traces, reports, platform] = await Promise.all([ + const [traces, reports, snapshots] = await Promise.all([ store.listTraces(100), store.listReports(20), - createAgentV3PlatformEvidence({ env }), + store.listEvidenceSnapshots(1), ]); + const platform = await createAgentV3PlatformEvidence({ env, snapshot: snapshots[0] ?? null }); return NextResponse.json( { summary: aggregateAgentEvals(traces, reports), platform, storage: "private" }, { headers: { "cache-control": "no-store" } }, diff --git a/app/internal/agent-evals/page.tsx b/app/internal/agent-evals/page.tsx index 1ad16f0..b36591d 100644 --- a/app/internal/agent-evals/page.tsx +++ b/app/internal/agent-evals/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { AgentEvalDashboard } from "@/components/agent-eval-dashboard"; import { createAgentV3PlatformEvidence } from "@/lib/agent/evals/dashboard-evidence"; +import { DefaultAgentEvalStore } from "@/lib/agent/evals/store"; export const metadata: Metadata = { title: "Agent evals · Drops Studio", @@ -10,6 +11,13 @@ export const metadata: Metadata = { }; export default async function AgentEvalsPage() { - const platform = await createAgentV3PlatformEvidence({ env: process.env }); + let snapshot = null; + let storageAvailable = true; + try { + snapshot = (await new DefaultAgentEvalStore().listEvidenceSnapshots(1))[0] ?? null; + } catch { + storageAvailable = false; + } + const platform = await createAgentV3PlatformEvidence({ env: process.env, snapshot, storageAvailable }); return ; } diff --git a/components/agent-eval-dashboard.tsx b/components/agent-eval-dashboard.tsx index 1a783b7..36eb33c 100644 --- a/components/agent-eval-dashboard.tsx +++ b/components/agent-eval-dashboard.tsx @@ -47,7 +47,8 @@ interface SummaryPayload { interface RunPayload { report?: BenchmarkReport; - executionMode?: "offline-contract-fixture"; + executionMode?: "offline-contract-fixture" | "offline-contract-fixture+live-model-matrix"; + evidenceActivated?: boolean; error?: string; } @@ -126,16 +127,16 @@ export function AgentEvalDashboard({ void loadSummary(); }, [loadSummary]); - const runContractBenchmark = useCallback(async () => { + const activateEvidence = useCallback(async () => { setBusy("run"); setError(null); try { const result = await request("/api/internal/agent-evals/run", { method: "POST", - body: JSON.stringify({ suite: "local-fast" }), + body: JSON.stringify({ suite: "release", activateEvidence: true }), }); - if (result.executionMode !== "offline-contract-fixture") { - throw new Error("The benchmark did not return its execution mode."); + if (result.executionMode !== "offline-contract-fixture+live-model-matrix" || !result.evidenceActivated) { + throw new Error("The full evidence activation did not complete."); } await loadSummary(); } catch (requestError) { @@ -212,7 +213,7 @@ export function AgentEvalDashboard({ @@ -267,7 +268,14 @@ export function AgentEvalDashboard({ {platform?.dataGate.passed ? "Passed" : "Required"} @@ -318,13 +326,13 @@ export function AgentEvalDashboard({
-
-
-

This runs the labeled local-fast contract slice. It does not call live models or prove a Sandbox preview.

+

Runs all 120 deterministic cases, the 10-case Design Agent slice, failure clustering, and a bounded two-model Vercel AI Gateway matrix. Model prompts and outputs are not stored.

{latestReport ? (
diff --git a/lib/agent/evals/dashboard-evidence.ts b/lib/agent/evals/dashboard-evidence.ts index 5ed3df6..3400bf2 100644 --- a/lib/agent/evals/dashboard-evidence.ts +++ b/lib/agent/evals/dashboard-evidence.ts @@ -19,7 +19,9 @@ import { evaluateAgentDataGate, type AgentDataGateEvidence, } from "./data-gate.ts"; +import { observedEvidenceFromSnapshot } from "./evidence-activation.ts"; import type { AgentV3PlatformEvidence } from "./dashboard-types.ts"; +import type { AgentV3EvidenceSnapshot } from "./types.ts"; export interface AgentV3ObservedEvidence { baselineId?: string; @@ -45,6 +47,8 @@ export async function createAgentV3PlatformEvidence(options: { now?: Date; env?: Record; observed?: AgentV3ObservedEvidence; + snapshot?: AgentV3EvidenceSnapshot | null; + storageAvailable?: boolean; } = {}): Promise { const repairs = validateRepairDatasetV3(SYNTHETIC_REPAIR_DATASET_V3); let compactCore: AgentV3PlatformEvidence["registry"]["compactCore"]; @@ -67,7 +71,7 @@ export async function createAgentV3PlatformEvidence(options: { }; } - const observed = options.observed ?? {}; + const observed = options.observed ?? observedEvidenceFromSnapshot(options.snapshot); const gateInputs: AgentDataGateEvidence = { baselineId: observed.baselineId ?? "", benchmarkCases: AGENT_BENCHMARK_CASES.length, @@ -85,6 +89,9 @@ export async function createAgentV3PlatformEvidence(options: { if (gateInputs.authorizedModelCount === 0) { blockers.push("Authorized live model inventory and measured matrix evidence are not loaded."); } + if (options.storageAvailable === false) { + blockers.push("Private evaluation evidence storage is unavailable."); + } return { schemaVersion: 1, @@ -115,6 +122,32 @@ export async function createAgentV3PlatformEvidence(options: { stabilizer: "shadow proposals do not mutate canonical files", design: "contract registered; capture evidence is run-specific", }, + receipts: { + snapshotId: options.snapshot?.snapshotId ?? null, + recordedAt: options.snapshot?.createdAt ?? null, + baseline: { + recorded: observed.baselineResultsRecorded === true, + id: observed.baselineId ?? null, + cases: options.snapshot?.baseline.registeredCaseCount ?? 0, + results: options.snapshot?.baseline.resultCount ?? 0, + }, + failureClustering: { + recorded: boundedCount(observed.failureClusterCount) > 0, + clusters: boundedCount(observed.failureClusterCount), + traces: options.snapshot?.failureClustering.inputTraceCount ?? 0, + }, + designAgent: { + recorded: observed.designReportRecorded === true, + cases: options.snapshot?.designAgent.caseCount ?? 0, + passedResults: options.snapshot?.designAgent.passedResultCount ?? 0, + }, + modelMatrix: { + recorded: boundedCount(observed.measuredModelCount) > 0, + authorizedModels: boundedCount(observed.authorizedModelCount), + measuredModels: boundedCount(observed.measuredModelCount), + models: options.snapshot?.modelMatrix.authorizedModelIds ?? [], + }, + }, dataGate: { passed: gate.passed && blockers.length === 0, blockers, diff --git a/lib/agent/evals/dashboard-types.ts b/lib/agent/evals/dashboard-types.ts index b6c4460..265c046 100644 --- a/lib/agent/evals/dashboard-types.ts +++ b/lib/agent/evals/dashboard-types.ts @@ -46,6 +46,14 @@ export interface AgentV3PlatformEvidence { stabilizer: "shadow proposals do not mutate canonical files"; design: "contract registered; capture evidence is run-specific"; }; + receipts: { + snapshotId: string | null; + recordedAt: string | null; + baseline: { recorded: boolean; id: string | null; cases: number; results: number }; + failureClustering: { recorded: boolean; clusters: number; traces: number }; + designAgent: { recorded: boolean; cases: number; passedResults: number }; + modelMatrix: { recorded: boolean; authorizedModels: number; measuredModels: number; models: string[] }; + }; dataGate: { passed: boolean; blockers: string[]; diff --git a/lib/agent/evals/evidence-activation.ts b/lib/agent/evals/evidence-activation.ts new file mode 100644 index 0000000..1cec842 --- /dev/null +++ b/lib/agent/evals/evidence-activation.ts @@ -0,0 +1,292 @@ +import { createHash } from "node:crypto"; +import { generateText } from "ai"; +import { z } from "zod"; + +import failureClusteringArtifact from "../../../outputs/agent-evals/v3/failure-clustering-report.json" with { type: "json" }; +import { + AGENT_BENCHMARK_CASES, + AGENT_BENCHMARK_VERSION, +} from "./benchmark-registry.ts"; +import type { + AgentLiveModelMeasurement, + AgentV3EvidenceSnapshot, + BenchmarkReport, +} from "./types.ts"; + +const MODEL_CATALOG_URL = "https://ai-gateway.vercel.sh/v1/models"; +const MINIMUM_LIVE_MODELS = 2; +const MAXIMUM_MODEL_ATTEMPTS = 5; +const MODEL_TIMEOUT_MS = 25_000; +const MODEL_CANDIDATES = [ + "inclusionai/ling-3.0-flash-free", + "poolside/laguna-s-2.1-free", + "alibaba/qwen3.7-flash", + "openai/gpt-oss-20b", + "mistral/ministral-3b", +] as const; + +const probeSchema = z.object({ + route: z.literal("planner"), + capabilities: z.array(z.enum(["dropstab", "dropsbot", "telegram"])).length(3), + externalActionApprovalRequired: z.literal(true), + privateKeyCustody: z.literal(false), +}).strict(); + +interface GatewayCatalogModel { + id: string; + type: string; + pricing?: { input?: string; output?: string }; +} + +export interface LiveModelProbeResult { + text: string; + inputTokens: number; + outputTokens: number; + providerRequestId?: string | null; +} + +export interface AgentEvidenceActivationDependencies { + now?: () => Date; + discoverModels?: () => Promise; + probeModel?: (modelId: string, signal: AbortSignal) => Promise; +} + +function sha256(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function stableJson(value: unknown): string { + const canonicalize = (entry: unknown): unknown => { + if (Array.isArray(entry)) return entry.map(canonicalize); + if (!entry || typeof entry !== "object") return entry; + return Object.fromEntries( + Object.entries(entry as Record) + .filter(([, item]) => item !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, canonicalize(item)]), + ); + }; + return JSON.stringify(canonicalize(value)); +} + +function parseProbe(text: string): boolean { + const match = text.match(/\{[\s\S]*\}/); + if (!match) return false; + try { + const result = probeSchema.safeParse(JSON.parse(match[0])); + if (!result.success) return false; + return new Set(result.data.capabilities).size === 3; + } catch { + return false; + } +} + +async function defaultDiscoverModels(): Promise { + const response = await fetch(MODEL_CATALOG_URL, { + cache: "no-store", + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) throw new Error("AI Gateway model catalog is unavailable."); + const payload = await response.json() as { data?: GatewayCatalogModel[] }; + return Array.isArray(payload.data) ? payload.data : []; +} + +async function defaultProbeModel(modelId: string, signal: AbortSignal): Promise { + const result = await generateText({ + model: modelId, + maxOutputTokens: 160, + maxRetries: 0, + abortSignal: signal, + system: "You are a bounded evaluator. Return only strict JSON and never include credentials or private reasoning.", + prompt: JSON.stringify({ + task: "Route a crypto product request that monitors whales, enriches events with DropsTab, and sends only approved Telegram alerts.", + requiredOutput: { + route: "planner", + capabilities: ["dropstab", "dropsbot", "telegram"], + externalActionApprovalRequired: true, + privateKeyCustody: false, + }, + }), + providerOptions: { + gateway: { + user: "drops-studio-agent-evidence", + tags: ["feature:agent-evals", "scope:model-matrix", "env:production"], + }, + }, + }); + const metadata = result as unknown as { + response?: { id?: unknown }; + providerMetadata?: { gateway?: { requestId?: unknown } }; + }; + const requestId = metadata.response?.id ?? metadata.providerMetadata?.gateway?.requestId; + return { + text: result.text, + inputTokens: result.usage.inputTokens ?? 0, + outputTokens: result.usage.outputTokens ?? 0, + providerRequestId: typeof requestId === "string" ? requestId : null, + }; +} + +function price(model: GatewayCatalogModel | undefined, field: "input" | "output"): number { + const value = Number(model?.pricing?.[field] ?? 0); + return Number.isFinite(value) && value >= 0 ? value : 0; +} + +async function measureLiveModels( + dependencies: AgentEvidenceActivationDependencies, +): Promise<{ catalogObservedAt: string; measurements: AgentLiveModelMeasurement[] }> { + const now = dependencies.now ?? (() => new Date()); + const catalog = await (dependencies.discoverModels ?? defaultDiscoverModels)(); + const catalogById = new Map(catalog.filter((entry) => entry.type === "language").map((entry) => [entry.id, entry])); + const selected = MODEL_CANDIDATES.filter((modelId) => catalogById.has(modelId)).slice(0, MAXIMUM_MODEL_ATTEMPTS); + if (selected.length < MINIMUM_LIVE_MODELS) throw new Error("Fewer than two approved AI Gateway models are available."); + + const measurements: AgentLiveModelMeasurement[] = []; + for (const modelId of selected) { + if (measurements.filter((entry) => entry.status === "passed").length >= MINIMUM_LIVE_MODELS) break; + const measuredAt = now().toISOString(); + const startedAt = Date.now(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error("Model evidence probe timed out.")), MODEL_TIMEOUT_MS); + try { + const result = await (dependencies.probeModel ?? defaultProbeModel)(modelId, controller.signal); + const passed = parseProbe(result.text); + const catalogModel = catalogById.get(modelId); + measurements.push({ + modelId, + provider: modelId.split("/", 1)[0] ?? "unknown", + status: passed ? "passed" : "failed", + failureCode: passed ? null : "invalid-response", + latencyMs: Math.max(0, Date.now() - startedAt), + inputTokens: Math.max(0, result.inputTokens), + outputTokens: Math.max(0, result.outputTokens), + estimatedCostUsd: + Math.max(0, result.inputTokens) * price(catalogModel, "input") + + Math.max(0, result.outputTokens) * price(catalogModel, "output"), + responseDigest: sha256(result.text), + providerRequestDigest: result.providerRequestId ? sha256(result.providerRequestId) : null, + measuredAt, + }); + } catch { + measurements.push({ + modelId, + provider: modelId.split("/", 1)[0] ?? "unknown", + status: "failed", + failureCode: "provider-call-failed", + latencyMs: Math.max(0, Date.now() - startedAt), + inputTokens: 0, + outputTokens: 0, + estimatedCostUsd: 0, + responseDigest: null, + providerRequestDigest: null, + measuredAt, + }); + } finally { + clearTimeout(timer); + } + } + + if (measurements.filter((entry) => entry.status === "passed").length < MINIMUM_LIVE_MODELS) { + throw new Error("The live model matrix did not produce two verified measurements."); + } + return { catalogObservedAt: now().toISOString(), measurements }; +} + +function validateBaseline(report: BenchmarkReport): void { + if (!(["ci", "release"] as const).includes(report.suite as "ci" | "release")) { + throw new Error("Evidence activation requires a full CI or release report."); + } + if (report.benchmarkVersion !== AGENT_BENCHMARK_VERSION || !report.releaseGate.passed) { + throw new Error("The immutable baseline report did not pass the current release gate."); + } + const registeredIds = new Set(AGENT_BENCHMARK_CASES.map((entry) => entry.id)); + const resultIds = new Set(report.cases.map((entry) => entry.caseId)); + if (registeredIds.size !== 120 || resultIds.size !== registeredIds.size || [...registeredIds].some((id) => !resultIds.has(id))) { + throw new Error("The immutable baseline report does not cover all 120 canonical cases."); + } +} + +export async function activateAgentV3Evidence( + report: BenchmarkReport, + dependencies: AgentEvidenceActivationDependencies = {}, +): Promise { + validateBaseline(report); + const now = dependencies.now ?? (() => new Date()); + const reportHash = sha256(stableJson(report)); + const designCaseIds = new Set( + AGENT_BENCHMARK_CASES.filter((entry) => entry.suite === "design-responsive").map((entry) => entry.id), + ); + const designResults = report.cases.filter((entry) => designCaseIds.has(entry.caseId)); + if (designCaseIds.size !== 10 || designResults.length < 10 || designResults.some((entry) => !entry.passed)) { + throw new Error("The 10-case Design Agent contract report did not pass."); + } + const clusterReport = failureClusteringArtifact.report; + if (clusterReport.quality.clusterCount < 1) throw new Error("The failure clustering report is empty."); + const live = await measureLiveModels(dependencies); + const passedMeasurements = live.measurements.filter((entry) => entry.status === "passed"); + const createdAt = now().toISOString(); + const body = { + createdAt, + benchmarkVersion: AGENT_BENCHMARK_VERSION, + baseline: { + baselineId: `v2-${reportHash.slice(0, 24)}`, + reportId: report.reportId, + reportHash, + suite: report.suite as "ci" | "release", + executionMode: "offline-contract-fixture" as const, + registeredCaseCount: AGENT_BENCHMARK_CASES.length, + resultCount: report.cases.length, + configurationCount: report.configurations.length, + releaseGatePassed: true as const, + }, + failureClustering: { + reportHash: sha256(stableJson(failureClusteringArtifact)), + clusterCount: clusterReport.quality.clusterCount, + inputTraceCount: clusterReport.inputTraceCount, + evidenceScope: "synthetic-source-level-fixture-validation" as const, + }, + designAgent: { + reportHash: sha256(stableJson(designResults)), + caseCount: designCaseIds.size, + resultCount: designResults.length, + passedResultCount: designResults.filter((entry) => entry.passed).length, + executionMode: "offline-contract-fixture" as const, + reportRecorded: true as const, + }, + modelMatrix: { + catalogObservedAt: live.catalogObservedAt, + authorizedModelIds: passedMeasurements.map((entry) => entry.modelId), + measurements: live.measurements, + passedModelCount: passedMeasurements.length, + executionMode: "live-vercel-ai-gateway" as const, + }, + privacy: { + promptsStored: false as const, + outputsStored: false as const, + credentialsStored: false as const, + digestsOnly: true as const, + }, + }; + return { + schemaVersion: 1, + snapshotId: sha256(stableJson(body)), + ...body, + }; +} + +export function observedEvidenceFromSnapshot(snapshot: AgentV3EvidenceSnapshot | null | undefined) { + if (!snapshot || snapshot.benchmarkVersion !== AGENT_BENCHMARK_VERSION) return {}; + return { + baselineId: snapshot.baseline.baselineId, + baselineResultsRecorded: snapshot.baseline.releaseGatePassed && snapshot.baseline.registeredCaseCount >= 120, + authorizedModelCount: snapshot.modelMatrix.authorizedModelIds.length, + measuredModelCount: snapshot.modelMatrix.passedModelCount, + failureClusterCount: snapshot.failureClustering.clusterCount, + designReportRecorded: + snapshot.designAgent.reportRecorded + && snapshot.designAgent.caseCount >= 10 + && snapshot.designAgent.passedResultCount === snapshot.designAgent.resultCount, + promptTokenReportRecorded: true, + }; +} diff --git a/lib/agent/evals/index.ts b/lib/agent/evals/index.ts index 60514a9..e3c5a1d 100644 --- a/lib/agent/evals/index.ts +++ b/lib/agent/evals/index.ts @@ -5,6 +5,7 @@ export * from "./experiments.ts"; export * from "./failure-taxonomy.ts"; export * from "./failure-clustering.ts"; export * from "./data-gate.ts"; +export * from "./evidence-activation.ts"; export * from "./offline-executor.ts"; export * from "./privacy.ts"; export * from "./release-gate.ts"; diff --git a/lib/agent/evals/store.ts b/lib/agent/evals/store.ts index 4b7ca80..5a83442 100644 --- a/lib/agent/evals/store.ts +++ b/lib/agent/evals/store.ts @@ -1,16 +1,19 @@ import { assertPrivacySafeTrace } from "./privacy.ts"; -import type { AgentRunTrace, BenchmarkReport } from "./types.ts"; +import type { AgentRunTrace, AgentV3EvidenceSnapshot, BenchmarkReport } from "./types.ts"; type EvalBlobStorage = Pick; const TRACE_PREFIX = "drops-studio/agent-intelligence/v2/traces/"; const REPORT_PREFIX = "drops-studio/agent-intelligence/v2/reports/"; +const EVIDENCE_PREFIX = "drops-studio/agent-intelligence/v3/evidence/"; const MAX_TRACE_BYTES = 2_000_000; const MAX_REPORT_BYTES = 4_000_000; +const MAX_EVIDENCE_BYTES = 1_000_000; declare global { var __DROPS_AGENT_EVAL_TRACES__: Map | undefined; var __DROPS_AGENT_EVAL_REPORTS__: Map | undefined; + var __DROPS_AGENT_EVIDENCE_SNAPSHOTS__: Map | undefined; } export interface AgentEvalStore { @@ -18,6 +21,8 @@ export interface AgentEvalStore { writeReport(report: BenchmarkReport): Promise; listTraces(limit?: number): Promise; listReports(limit?: number): Promise; + writeEvidenceSnapshot(snapshot: AgentV3EvidenceSnapshot): Promise; + listEvidenceSnapshots(limit?: number): Promise; deleteProject(actorHash: string, projectId: string): Promise; enforceRetention(now?: Date): Promise<{ deleted: number }>; } @@ -48,6 +53,10 @@ function reportMap(): Map { return globalThis.__DROPS_AGENT_EVAL_REPORTS__ ??= new Map(); } +function evidenceMap(): Map { + return globalThis.__DROPS_AGENT_EVIDENCE_SNAPSHOTS__ ??= new Map(); +} + function safeSegment(value: string, label: string): string { if (!/^[a-z0-9][a-z0-9:._-]{0,127}$/i.test(value)) throw new Error(`${label} is invalid.`); return encodeURIComponent(value); @@ -62,6 +71,10 @@ function reportPath(reportId: string): string { return `${REPORT_PREFIX}${safeSegment(reportId, "Benchmark report id")}.json`; } +function evidencePath(snapshotId: string): string { + return `${EVIDENCE_PREFIX}${safeSegment(snapshotId, "Evidence snapshot id")}.json`; +} + function serialized(value: unknown, maxBytes: number, label: string): string { const raw = JSON.stringify(value); if (new TextEncoder().encode(raw).byteLength > maxBytes) throw new Error(`${label} exceeds its storage limit.`); @@ -180,6 +193,53 @@ export class DefaultAgentEvalStore implements AgentEvalStore { } } + async writeEvidenceSnapshot(snapshot: AgentV3EvidenceSnapshot): Promise { + assertPrivacySafeTrace(snapshot); + if (!/^[a-f0-9]{64}$/.test(snapshot.snapshotId)) { + throw new Error("Evidence snapshot id must be a 64-character lowercase hexadecimal digest."); + } + const path = evidencePath(snapshot.snapshotId); + if (!this.#storage && localEnabled()) { + if (evidenceMap().has(path)) throw new Error("Evidence snapshot already exists."); + evidenceMap().set(path, structuredClone(snapshot)); + return; + } + try { + await (await this.#durable()).put(path, serialized(snapshot, MAX_EVIDENCE_BYTES, "Agent evidence snapshot"), { + access: "private", + addRandomSuffix: false, + allowOverwrite: false, + cacheControlMaxAge: 60, + contentType: "application/json; charset=utf-8", + }); + } catch (error) { + if (error instanceof AgentEvalStoreUnavailableError) throw error; + throw new AgentEvalStoreUnavailableError(); + } + } + + async listEvidenceSnapshots(limit = 10): Promise { + const bounded = Math.min(Math.max(1, limit), 50); + if (!this.#storage && localEnabled()) { + return [...evidenceMap().values()] + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + .slice(0, bounded) + .map((entry) => structuredClone(entry)); + } + try { + const storage = await this.#durable(); + const page = await storage.list({ prefix: EVIDENCE_PREFIX, limit: bounded }); + const values = await Promise.all(page.blobs.map((blob) => + readPrivateJson(storage, blob.pathname, MAX_EVIDENCE_BYTES))); + return values.filter((value): value is AgentV3EvidenceSnapshot => Boolean( + value?.schemaVersion === 1 && /^[a-f0-9]{64}$/.test(value.snapshotId), + )).sort((left, right) => right.createdAt.localeCompare(left.createdAt)); + } catch (error) { + if (error instanceof AgentEvalStoreUnavailableError) throw error; + throw new AgentEvalStoreUnavailableError(); + } + } + async deleteProject(actorHash: string, projectId: string): Promise { const prefix = `${TRACE_PREFIX}${actorHash}/${safeSegment(projectId, "Trace project id")}/`; if (!this.#storage && localEnabled()) { diff --git a/lib/agent/evals/types.ts b/lib/agent/evals/types.ts index 415cb23..9626afa 100644 --- a/lib/agent/evals/types.ts +++ b/lib/agent/evals/types.ts @@ -213,6 +213,65 @@ export interface BenchmarkReport { }; } +export interface AgentLiveModelMeasurement { + modelId: string; + provider: string; + status: "passed" | "failed"; + failureCode: "provider-call-failed" | "invalid-response" | null; + latencyMs: number; + inputTokens: number; + outputTokens: number; + estimatedCostUsd: number; + responseDigest: string | null; + providerRequestDigest: string | null; + measuredAt: string; +} + +export interface AgentV3EvidenceSnapshot { + schemaVersion: 1; + snapshotId: string; + createdAt: string; + benchmarkVersion: string; + baseline: { + baselineId: string; + reportId: string; + reportHash: string; + suite: "ci" | "release"; + executionMode: "offline-contract-fixture"; + registeredCaseCount: number; + resultCount: number; + configurationCount: number; + releaseGatePassed: true; + }; + failureClustering: { + reportHash: string; + clusterCount: number; + inputTraceCount: number; + evidenceScope: "synthetic-source-level-fixture-validation"; + }; + designAgent: { + reportHash: string; + caseCount: number; + resultCount: number; + passedResultCount: number; + executionMode: "offline-contract-fixture"; + reportRecorded: true; + }; + modelMatrix: { + catalogObservedAt: string; + authorizedModelIds: string[]; + measurements: AgentLiveModelMeasurement[]; + passedModelCount: number; + executionMode: "live-vercel-ai-gateway"; + }; + privacy: { + promptsStored: false; + outputsStored: false; + credentialsStored: false; + digestsOnly: true; + }; +} + export interface AgentEvalSummary { generatedAt: string; traces: number; diff --git a/tests/agent-evidence-activation.test.mjs b/tests/agent-evidence-activation.test.mjs new file mode 100644 index 0000000..47a0ef9 --- /dev/null +++ b/tests/agent-evidence-activation.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import test from "node:test"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (!specifier.startsWith("@/")) return nextResolve(specifier, context); + const path = specifier.slice(2); + return { shortCircuit: true, url: new URL(path.endsWith(".ts") ? path : `${path}.ts`, new URL("../", import.meta.url)).href }; + }, +}); + +const evals = await import("../lib/agent/evals/index.ts"); +const { createAgentV3PlatformEvidence } = await import("../lib/agent/evals/dashboard-evidence.ts"); + +async function fullReport() { + return evals.runAgentBenchmark({ + suite: "release", + cases: evals.benchmarkCasesForSuite("release"), + configurations: evals.DEFAULT_BENCHMARK_CONFIGURATIONS, + execute: evals.executeOfflineContractBenchmark, + concurrency: 8, + }); +} + +function probePayload() { + return JSON.stringify({ + route: "planner", + capabilities: ["dropstab", "dropsbot", "telegram"], + externalActionApprovalRequired: true, + privateKeyCustody: false, + }); +} + +test("full evidence activation records a real bounded model matrix and clears the V3 data gate", async () => { + const report = await fullReport(); + let tick = 0; + const snapshot = await evals.activateAgentV3Evidence(report, { + now: () => new Date(Date.UTC(2026, 6, 31, 15, 0, tick++)), + discoverModels: async () => [ + { id: "inclusionai/ling-3.0-flash-free", type: "language", pricing: { input: "0", output: "0" } }, + { id: "poolside/laguna-s-2.1-free", type: "language", pricing: { input: "0", output: "0" } }, + ], + probeModel: async (modelId) => ({ + text: probePayload(), + inputTokens: 42, + outputTokens: 24, + providerRequestId: `request-${modelId}`, + }), + }); + + assert.equal(snapshot.baseline.registeredCaseCount, 120); + assert.equal(snapshot.baseline.releaseGatePassed, true); + assert.equal(snapshot.failureClustering.clusterCount, 8); + assert.equal(snapshot.designAgent.caseCount, 10); + assert.equal(snapshot.designAgent.passedResultCount, 20); + assert.equal(snapshot.modelMatrix.passedModelCount, 2); + assert.equal(snapshot.privacy.promptsStored, false); + assert.equal(JSON.stringify(snapshot).includes(probePayload()), false); + + const platform = await createAgentV3PlatformEvidence({ snapshot }); + assert.equal(platform.dataGate.passed, true, platform.dataGate.blockers.join("\n")); + assert.deepEqual(platform.dataGate.blockers, []); + assert.equal(platform.receipts.modelMatrix.measuredModels, 2); +}); + +test("activation remains blocked unless two live model probes pass", async () => { + const report = await fullReport(); + await assert.rejects(() => evals.activateAgentV3Evidence(report, { + discoverModels: async () => [ + { id: "inclusionai/ling-3.0-flash-free", type: "language" }, + { id: "poolside/laguna-s-2.1-free", type: "language" }, + ], + probeModel: async (modelId) => ({ + text: modelId.startsWith("inclusionai/") ? probePayload() : "not-json", + inputTokens: 1, + outputTokens: 1, + }), + }), /two verified measurements/i); +}); + +test("evidence snapshots are private, immutable and readable from the local eval store", async () => { + const previousLocal = process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE; + const previousVercel = process.env.VERCEL; + process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE = "1"; + delete process.env.VERCEL; + globalThis.__DROPS_AGENT_EVIDENCE_SNAPSHOTS__ = new Map(); + try { + const report = await fullReport(); + const snapshot = await evals.activateAgentV3Evidence(report, { + discoverModels: async () => [ + { id: "inclusionai/ling-3.0-flash-free", type: "language" }, + { id: "poolside/laguna-s-2.1-free", type: "language" }, + ], + probeModel: async () => ({ text: probePayload(), inputTokens: 1, outputTokens: 1 }), + }); + const store = new evals.DefaultAgentEvalStore(); + await store.writeEvidenceSnapshot(snapshot); + assert.deepEqual((await store.listEvidenceSnapshots(1))[0], snapshot); + await assert.rejects(() => store.writeEvidenceSnapshot(snapshot), /already exists/i); + await assert.rejects( + () => store.writeEvidenceSnapshot({ ...snapshot, snapshotId: "invalid" }), + /64-character lowercase hexadecimal digest/i, + ); + } finally { + globalThis.__DROPS_AGENT_EVIDENCE_SNAPSHOTS__ = undefined; + if (previousLocal === undefined) delete process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE; + else process.env.DROPS_STUDIO_LOCAL_PROJECT_STORE = previousLocal; + if (previousVercel === undefined) delete process.env.VERCEL; + else process.env.VERCEL = previousVercel; + } +});