| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { and, eq, sql } from "drizzle-orm"; |
| import { createHash } from "node:crypto"; |
| import { |
| db, |
| toolSpawnTemplates, |
| type ToolSpawnTemplateRow, |
| type InsertToolSpawnTemplateRow, |
| } from "@workspace/db"; |
| import { newId } from "./ids"; |
| import { logger } from "./logger"; |
| import type { IOSchema } from "./tool-graph"; |
|
|
| export const FINGERPRINT_ALGO_VERSION = 1; |
|
|
| |
| export type SeamFailureMode = |
| | "missing_required_field" |
| | "type_mismatch" |
| | "capability_gap" |
| | "unknown"; |
|
|
| export interface SeamFingerprintInput { |
| failureMode: SeamFailureMode; |
| |
| downstreamInputSchema: IOSchema | null | undefined; |
| |
| upstreamOutputSchema?: IOSchema | null | undefined; |
| |
| |
| |
| |
| |
| missingRequiredFieldNames?: string[]; |
| |
| |
| |
| |
| |
| unusedFieldNames?: string[]; |
| |
| capabilityTag?: string; |
| } |
|
|
| export interface SeamFingerprintBreakdown { |
| failureMode: SeamFailureMode; |
| missingFieldTypes: string[]; |
| unusedFieldTypes: string[]; |
| downstreamInputSchemaFingerprint: string; |
| capabilityTag: string; |
| algoVersion: number; |
| } |
|
|
| export interface ComputedFingerprint { |
| hash: string; |
| breakdown: SeamFingerprintBreakdown; |
| |
| canonical: string; |
| } |
|
|
| |
|
|
| const DEV_SALT_FALLBACK = "doatlas-dev-template-salt-v1"; |
| let warnedSaltOnce = false; |
| function getSalt(): string { |
| const env = (process.env["TEMPLATE_FINGERPRINT_SALT"] || "").trim(); |
| if (env) return env; |
| if (!warnedSaltOnce) { |
| warnedSaltOnce = true; |
| logger.warn( |
| "TEMPLATE_FINGERPRINT_SALT is unset — using a deterministic dev-only fallback. Set this env in production so template fingerprints cannot be correlated across deployments.", |
| ); |
| } |
| return DEV_SALT_FALLBACK; |
| } |
|
|
| |
| function typeToken(t: IOSchema["type"] | undefined): string { |
| return (t || "any") as string; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function schemaFingerprint( |
| schema: IOSchema | null | undefined, |
| ): string { |
| if (!schema || typeof schema !== "object") return "{}"; |
| const t = typeToken(schema.type); |
| const items = schema.items |
| ? `items=${schemaFingerprint(schema.items)}` |
| : ""; |
| const props = schema.properties || {}; |
| const requiredSet = new Set(schema.required || []); |
| const childFps: string[] = []; |
| for (const [name, sub] of Object.entries(props)) { |
| const req = requiredSet.has(name) ? "1" : "0"; |
| childFps.push(`${req}:${schemaFingerprint(sub)}`); |
| } |
| childFps.sort(); |
| const propsPart = childFps.length ? `props=[${childFps.join(",")}]` : ""; |
| return `{t=${t}${propsPart ? "," + propsPart : ""}${items ? "," + items : ""}}`; |
| } |
|
|
| |
| |
| |
| |
| |
| function resolveFieldTypes( |
| parent: IOSchema | null | undefined, |
| fieldNames: string[] | undefined, |
| ): string[] { |
| if (!parent || !parent.properties || !fieldNames || fieldNames.length === 0) { |
| return []; |
| } |
| const props = parent.properties; |
| const out: string[] = []; |
| for (const name of fieldNames) { |
| const sub = props[name]; |
| out.push(typeToken(sub?.type)); |
| } |
| out.sort(); |
| return out; |
| } |
|
|
| export function computeSeamFingerprint( |
| input: SeamFingerprintInput, |
| ): ComputedFingerprint { |
| const breakdown: SeamFingerprintBreakdown = { |
| failureMode: input.failureMode, |
| missingFieldTypes: resolveFieldTypes( |
| input.downstreamInputSchema, |
| input.missingRequiredFieldNames, |
| ), |
| unusedFieldTypes: resolveFieldTypes( |
| input.upstreamOutputSchema, |
| input.unusedFieldNames, |
| ), |
| downstreamInputSchemaFingerprint: schemaFingerprint( |
| input.downstreamInputSchema, |
| ), |
| capabilityTag: (input.capabilityTag || "").toLowerCase(), |
| algoVersion: FINGERPRINT_ALGO_VERSION, |
| }; |
| |
| |
| |
| |
| |
| |
| const canonical = [ |
| `v=${breakdown.algoVersion}`, |
| `mode=${breakdown.failureMode}`, |
| `miss=[${breakdown.missingFieldTypes.join(",")}]`, |
| `unused=[${breakdown.unusedFieldTypes.join(",")}]`, |
| `dsfp=${breakdown.downstreamInputSchemaFingerprint}`, |
| ].join("|"); |
| const hash = createHash("sha256") |
| .update(getSalt()) |
| .update("\x00") |
| .update(canonical) |
| .digest("hex"); |
| return { hash, breakdown, canonical }; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const SPEC_ALLOWED_TOP = new Set([ |
| "name", |
| "description", |
| "parameters", |
| "inputSchema", |
| "outputSchema", |
| ]); |
| const SCHEMA_ALLOWED_KEYS = new Set([ |
| "type", |
| "properties", |
| "required", |
| "items", |
| "description", |
| "enum", |
| "format", |
| "minimum", |
| "maximum", |
| "minLength", |
| "maxLength", |
| "pattern", |
| ]); |
|
|
| function stripSchema(node: unknown): unknown { |
| if (!node || typeof node !== "object") return node; |
| if (Array.isArray(node)) return node.map(stripSchema); |
| const out: Record<string, unknown> = {}; |
| for (const [k, v] of Object.entries(node as Record<string, unknown>)) { |
| if (!SCHEMA_ALLOWED_KEYS.has(k)) continue; |
| |
| |
| |
| |
| |
| if ( |
| k === "description" || |
| k === "enum" || |
| k === "pattern" || |
| k === "default" || |
| k === "examples" |
| ) { |
| continue; |
| } |
| if (k === "properties" && v && typeof v === "object") { |
| const cleaned: Record<string, unknown> = {}; |
| for (const [pk, pv] of Object.entries(v as Record<string, unknown>)) { |
| cleaned[pk] = stripSchema(pv); |
| } |
| out[k] = cleaned; |
| } else if (k === "items") { |
| out[k] = stripSchema(v); |
| } else { |
| out[k] = v; |
| } |
| } |
| return out; |
| } |
|
|
| export function sanitizeSpecSkeleton( |
| spec: Record<string, unknown> | null | undefined, |
| ): Record<string, unknown> { |
| if (!spec || typeof spec !== "object") return {}; |
| const out: Record<string, unknown> = {}; |
| for (const [k, v] of Object.entries(spec)) { |
| if (!SPEC_ALLOWED_TOP.has(k)) continue; |
| if (k === "name" && typeof v === "string") { |
| |
| out[k] = v.slice(0, 120); |
| } else if (k === "description") { |
| |
| |
| |
| |
| continue; |
| } else if (k === "parameters" || k === "inputSchema" || k === "outputSchema") { |
| out[k] = stripSchema(v); |
| } |
| } |
| return out; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function sanitizeHandlerSkeleton(skel: string | null | undefined): string { |
| if (!skel) return ""; |
| let out = skel; |
| |
| |
| |
| |
| |
| out = out.replace(/"((?:\\.|[^"\\\n])*)"/g, '"<value>"'); |
| out = out.replace(/'((?:\\.|[^'\\\n])*)'/g, "'<value>'"); |
| out = out.replace(/`((?:\\.|[^`\\])*)`/g, "`<value>`"); |
| |
| out = out.replace(/\/\*[\s\S]*?\*\//g, ""); |
| out = out.replace(/(^|[^:])\/\/[^\n]*/g, "$1"); |
| |
| if (out.length > 8000) out = out.slice(0, 8000) + "\n// truncated\n"; |
| return out; |
| } |
|
|
| |
|
|
| export interface TemplateMatch { |
| templateId: string; |
| strength: "exact" | "near"; |
| score: number; |
| template: ToolSpawnTemplateRow; |
| |
| |
| |
| |
| |
| parameterizationGaps: string[]; |
| } |
|
|
| const NEAR_MATCH_JACCARD_THRESHOLD = 0.8; |
|
|
| function jaccard(a: Set<string>, b: Set<string>): number { |
| if (a.size === 0 && b.size === 0) return 1; |
| let inter = 0; |
| for (const x of a) if (b.has(x)) inter += 1; |
| const union = a.size + b.size - inter; |
| return union === 0 ? 1 : inter / union; |
| } |
|
|
| export async function searchTemplates( |
| input: SeamFingerprintInput, |
| ): Promise<TemplateMatch | null> { |
| let computed: ComputedFingerprint; |
| try { |
| computed = computeSeamFingerprint(input); |
| } catch (err) { |
| logger.debug({ err }, "spawn-templates: fingerprint failed"); |
| return null; |
| } |
| const breakdown = computed.breakdown; |
|
|
| |
| const exact = await db |
| .select() |
| .from(toolSpawnTemplates) |
| .where( |
| and( |
| eq(toolSpawnTemplates.fingerprintHash, computed.hash), |
| eq(toolSpawnTemplates.fingerprintAlgoVersion, FINGERPRINT_ALGO_VERSION), |
| eq(toolSpawnTemplates.status, "active"), |
| ), |
| ) |
| .limit(1); |
| if (exact[0]) { |
| return { |
| templateId: exact[0].id, |
| strength: "exact", |
| score: 1, |
| template: exact[0], |
| parameterizationGaps: [], |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| const candidates = await db |
| .select() |
| .from(toolSpawnTemplates) |
| .where( |
| and( |
| eq(toolSpawnTemplates.failureMode, breakdown.failureMode), |
| eq( |
| toolSpawnTemplates.downstreamInputSchemaFingerprint, |
| breakdown.downstreamInputSchemaFingerprint, |
| ), |
| eq(toolSpawnTemplates.fingerprintAlgoVersion, FINGERPRINT_ALGO_VERSION), |
| eq(toolSpawnTemplates.status, "active"), |
| ), |
| ); |
| if (candidates.length === 0) return null; |
| |
| |
| const reqBag = new Set([ |
| ...breakdown.missingFieldTypes.map((t, i) => `m:${i}:${t}`), |
| ...breakdown.unusedFieldTypes.map((t, i) => `u:${i}:${t}`), |
| ]); |
| let best: TemplateMatch | null = null; |
| for (const t of candidates) { |
| const tBag = new Set([ |
| ...((t.missingFieldTypes as string[]) || []).map( |
| (tt, i) => `m:${i}:${tt}`, |
| ), |
| ...((t.unusedFieldTypes as string[]) || []).map( |
| (tt, i) => `u:${i}:${tt}`, |
| ), |
| ]); |
| const score = jaccard(reqBag, tBag); |
| if (score < NEAR_MATCH_JACCARD_THRESHOLD) continue; |
| if (best && best.score >= score) continue; |
| const gaps: string[] = []; |
| if ( |
| JSON.stringify(t.missingFieldTypes) !== |
| JSON.stringify(breakdown.missingFieldTypes) |
| ) { |
| gaps.push("missing_field_types"); |
| } |
| if ( |
| JSON.stringify(t.unusedFieldTypes) !== |
| JSON.stringify(breakdown.unusedFieldTypes) |
| ) { |
| gaps.push("unused_field_types"); |
| } |
| best = { |
| templateId: t.id, |
| strength: "near", |
| score, |
| template: t, |
| parameterizationGaps: gaps, |
| }; |
| } |
| return best; |
| } |
|
|
| |
|
|
| export interface PromotePersistInput { |
| fingerprintInput: SeamFingerprintInput; |
| promotedNodeName: string; |
| promotedInputSchema: IOSchema | null | undefined; |
| promotedOutputSchema: IOSchema | null | undefined; |
| handlerSkeleton: string; |
| specSkeleton: Record<string, unknown>; |
| } |
|
|
| |
| |
| |
| |
| |
| export async function persistTemplateOnPromote( |
| input: PromotePersistInput, |
| ): Promise<ToolSpawnTemplateRow | null> { |
| try { |
| const computed = computeSeamFingerprint(input.fingerprintInput); |
| const cleanedSpec = sanitizeSpecSkeleton(input.specSkeleton); |
| const cleanedHandler = sanitizeHandlerSkeleton(input.handlerSkeleton); |
| const row: InsertToolSpawnTemplateRow = { |
| id: newId("tspt"), |
| fingerprintHash: computed.hash, |
| fingerprintAlgoVersion: FINGERPRINT_ALGO_VERSION, |
| failureMode: computed.breakdown.failureMode, |
| missingFieldTypes: computed.breakdown.missingFieldTypes, |
| unusedFieldTypes: computed.breakdown.unusedFieldTypes, |
| downstreamInputSchemaFingerprint: |
| computed.breakdown.downstreamInputSchemaFingerprint, |
| promotedInputSchemaFingerprint: schemaFingerprint(input.promotedInputSchema), |
| promotedOutputSchemaFingerprint: schemaFingerprint( |
| input.promotedOutputSchema, |
| ), |
| handlerSkeleton: cleanedHandler, |
| specSkeleton: cleanedSpec, |
| capabilityTag: computed.breakdown.capabilityTag, |
| sourceNodeName: input.promotedNodeName.slice(0, 200), |
| offeredCount: 0, |
| reuseCount: 0, |
| successCount: 0, |
| rejectCount: 0, |
| status: "active", |
| }; |
| const inserted = await db |
| .insert(toolSpawnTemplates) |
| .values(row) |
| .onConflictDoUpdate({ |
| target: [ |
| toolSpawnTemplates.fingerprintHash, |
| toolSpawnTemplates.fingerprintAlgoVersion, |
| ], |
| set: { |
| handlerSkeleton: row.handlerSkeleton, |
| specSkeleton: row.specSkeleton, |
| promotedInputSchemaFingerprint: row.promotedInputSchemaFingerprint, |
| promotedOutputSchemaFingerprint: row.promotedOutputSchemaFingerprint, |
| sourceNodeName: row.sourceNodeName, |
| version: sql`${toolSpawnTemplates.version} + 1`, |
| updatedAt: new Date(), |
| }, |
| }) |
| .returning(); |
| return inserted[0] ?? null; |
| } catch (err) { |
| logger.warn({ err }, "spawn-templates: persistTemplateOnPromote failed"); |
| return null; |
| } |
| } |
|
|
| |
|
|
| export async function recordTemplateOffered(templateId: string): Promise<void> { |
| try { |
| await db |
| .update(toolSpawnTemplates) |
| .set({ |
| offeredCount: sql`${toolSpawnTemplates.offeredCount} + 1`, |
| updatedAt: new Date(), |
| }) |
| .where(eq(toolSpawnTemplates.id, templateId)); |
| } catch (err) { |
| logger.debug({ err }, "spawn-templates: recordTemplateOffered failed"); |
| } |
| } |
|
|
| export type TemplateChoice = "use" | "use_edit" | "fresh"; |
|
|
| export async function recordTemplateChoice(args: { |
| templateId: string; |
| choice: TemplateChoice; |
| }): Promise<void> { |
| try { |
| if (args.choice === "fresh") { |
| await db |
| .update(toolSpawnTemplates) |
| .set({ |
| rejectCount: sql`${toolSpawnTemplates.rejectCount} + 1`, |
| updatedAt: new Date(), |
| }) |
| .where(eq(toolSpawnTemplates.id, args.templateId)); |
| } else { |
| await db |
| .update(toolSpawnTemplates) |
| .set({ |
| reuseCount: sql`${toolSpawnTemplates.reuseCount} + 1`, |
| updatedAt: new Date(), |
| }) |
| .where(eq(toolSpawnTemplates.id, args.templateId)); |
| } |
| } catch (err) { |
| logger.debug({ err }, "spawn-templates: recordTemplateChoice failed"); |
| } |
| |
| |
| await maybeAutoDemote(args.templateId); |
| } |
|
|
| export async function recordTemplatePromoteResult( |
| templateId: string, |
| ): Promise<void> { |
| try { |
| await db |
| .update(toolSpawnTemplates) |
| .set({ |
| successCount: sql`${toolSpawnTemplates.successCount} + 1`, |
| updatedAt: new Date(), |
| }) |
| .where(eq(toolSpawnTemplates.id, templateId)); |
| } catch (err) { |
| logger.debug({ err }, "spawn-templates: recordTemplatePromoteResult failed"); |
| } |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| const MIN_OFFERS_FOR_DEMOTE = Number( |
| process.env["TEMPLATE_DEMOTE_MIN_OFFERS"] || 5, |
| ); |
| const SUCCESS_RATE_FLOOR = Number( |
| process.env["TEMPLATE_DEMOTE_SUCCESS_RATE"] || 0.3, |
| ); |
|
|
| export async function maybeAutoDemote( |
| templateId: string, |
| ): Promise<{ demoted: boolean; reason?: string }> { |
| try { |
| const rows = await db |
| .select() |
| .from(toolSpawnTemplates) |
| .where(eq(toolSpawnTemplates.id, templateId)) |
| .limit(1); |
| const t = rows[0]; |
| if (!t || t.status !== "active") return { demoted: false }; |
| if (t.offeredCount < MIN_OFFERS_FOR_DEMOTE) return { demoted: false }; |
| const rate = t.offeredCount === 0 ? 1 : t.successCount / t.offeredCount; |
| if (rate < SUCCESS_RATE_FLOOR) { |
| const reason = `success_rate ${rate.toFixed(2)} < floor ${SUCCESS_RATE_FLOOR} after ${t.offeredCount} offers`; |
| await db |
| .update(toolSpawnTemplates) |
| .set({ |
| status: "demoted", |
| demotedAt: new Date(), |
| demotedReason: reason, |
| updatedAt: new Date(), |
| }) |
| .where(eq(toolSpawnTemplates.id, templateId)); |
| logger.info( |
| { templateId, reason, sourceNode: t.sourceNodeName }, |
| "spawn-templates: auto-demoted template", |
| ); |
| return { demoted: true, reason }; |
| } |
| return { demoted: false }; |
| } catch (err) { |
| logger.debug({ err }, "spawn-templates: maybeAutoDemote failed"); |
| return { demoted: false }; |
| } |
| } |
|
|
| |
|
|
| export async function listTemplates(filter?: { |
| status?: "active" | "demoted" | "any"; |
| }): Promise<ToolSpawnTemplateRow[]> { |
| const status = filter?.status ?? "any"; |
| const q = db.select().from(toolSpawnTemplates); |
| const rows = |
| status === "any" |
| ? await q |
| : await db |
| .select() |
| .from(toolSpawnTemplates) |
| .where(eq(toolSpawnTemplates.status, status)); |
| rows.sort((a, b) => (b.updatedAt?.getTime() ?? 0) - (a.updatedAt?.getTime() ?? 0)); |
| return rows; |
| } |
|
|
| export async function setTemplateStatus( |
| templateId: string, |
| status: "active" | "demoted", |
| reason: string, |
| ): Promise<ToolSpawnTemplateRow | null> { |
| try { |
| const rows = await db |
| .update(toolSpawnTemplates) |
| .set({ |
| status, |
| demotedAt: status === "demoted" ? new Date() : null, |
| demotedReason: status === "demoted" ? reason.slice(0, 500) : null, |
| updatedAt: new Date(), |
| }) |
| .where(eq(toolSpawnTemplates.id, templateId)) |
| .returning(); |
| return rows[0] ?? null; |
| } catch (err) { |
| logger.debug({ err }, "spawn-templates: setTemplateStatus failed"); |
| return null; |
| } |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| export function applyParameterizationMarkers( |
| skeleton: string, |
| match: TemplateMatch, |
| ): string { |
| if (match.strength === "exact" || match.parameterizationGaps.length === 0) { |
| return skeleton; |
| } |
| const banner = match.parameterizationGaps |
| .map((g) => `// TODO(template-reuse): adjust for differing ${g}`) |
| .join("\n"); |
| return `${banner}\n${skeleton}`; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| export function __assertNoUserDataLeaked( |
| row: ToolSpawnTemplateRow, |
| payloadStrings: string[], |
| ): void { |
| const haystack = JSON.stringify({ |
| handler: row.handlerSkeleton, |
| spec: row.specSkeleton, |
| miss: row.missingFieldTypes, |
| unused: row.unusedFieldTypes, |
| ds: row.downstreamInputSchemaFingerprint, |
| promIn: row.promotedInputSchemaFingerprint, |
| promOut: row.promotedOutputSchemaFingerprint, |
| cap: row.capabilityTag, |
| src: row.sourceNodeName, |
| fp: row.fingerprintHash, |
| }); |
| for (const s of payloadStrings) { |
| if (s && s.length >= 4 && haystack.includes(s)) { |
| throw new Error( |
| `privacy invariant violated: payload string "${s.slice(0, 40)}…" leaked into template row ${row.id}`, |
| ); |
| } |
| } |
| } |
|
|