/** * Structured prompt contract output from the context compiler. * * A PromptContract is the final, budget-aware assembly of prompt sections * ready to be rendered into a prompt string. It carries full traceability * back to its data sources via traceId links on each section. */ import type { KernelSnapshot } from "../kernel/snapshots.ts"; import type { GateResult, KdFact, KdToolResultContract, KdWorkingSet } from "../types.ts"; import type { EvidenceSummary } from "../kernel/snapshots.ts"; // ── Section source taxonomy ────────────────────────────────────────── /** * Every prompt section must declare which data source produced its content. * This enables downstream consumers to trace each section back to its origin. */ export type PromptSectionSource = | "kernel-snapshot" | "run-state" | "facts" | "evidence" | "gate-decision" | "tool-result" | "ledger" | "project-context" | "policy-summary" | "control-frame" | "working-set" | "user-input"; // ── PromptSection ──────────────────────────────────────────────────── /** * A single section within the prompt contract. * * Each section carries: * - content produced by a specific data source * - a priority used for trim ordering (lower = higher priority, trimmed last) * - an optional character budget; when exceeded the content is truncated * - an `always` flag marking uncuttable sections (control-frame, user-input, etc.) * - a traceId linking back to the originating data record or snapshot */ export interface PromptSection { /** Stable identifier for this section (e.g. "status", "control"). */ id: string; /** Human-readable section heading. */ title: string; /** Section body content. */ content: string; /** Data source taxonomy. */ source: PromptSectionSource; /** Trim priority — lower values survive budget trimming first (default: 100). */ priority: number; /** Per-section character budget (optional override). When set, content exceeding this limit is truncated independently of the global budget. */ budget?: number; /** When true, this section is never trimmed regardless of budget pressure. */ always: boolean; /** Whether this section should be included in the final contract. When false, the section is excluded entirely. */ include: boolean; /** Traceability link to the originating data record (run.id, fact.key, evidence entry id, etc.). */ traceId: string; } // ── TrimLogEntry ───────────────────────────────────────────────────── /** * Records a single trim action applied to a section during budget enforcement. */ export interface TrimLogEntry { /** The section id that was trimmed. */ sectionId: string; /** Character count before trimming. */ originalChars: number; /** Character count after trimming. */ trimmedChars: number; /** Reason for the trim action. */ reason: "per-section-budget" | "global-budget" | "excluded"; } // ── PromptContract ─────────────────────────────────────────────────── /** * Final, budget-aware assembly of prompt sections. * * Produced by `PromptSectionRegistry.compile()`. Carries aggregate budget * metrics and a full trim log for audit purposes. */ export interface PromptContract { /** Ordered list of sections included in this contract. */ sections: PromptSection[]; /** Total character count across all included sections. */ totalChars: number; /** Characters consumed after trimming. */ budgetUsed: number; /** Total character budget for this contract. */ budgetTotal: number; /** Log of all trim actions applied during compilation. */ trimLog: TrimLogEntry[]; /** Traceability identifier for this contract assembly. */ traceId: string; } // ── CompileContext ──────────────────────────────────────────────────── /** * Input data supplied to `PromptSectionRegistry.compile()`. * * Each field corresponds to a potential prompt section source. */ export interface CompileContext { /** Current kernel snapshot capturing full run state. */ kernelSnapshot: KernelSnapshot; /** Gate decisions from the current run. */ gateDecisions: GateResult[]; /** Current facts from the run. */ facts: KdFact[]; /** Aggregated evidence summary. */ evidenceSummary: EvidenceSummary; /** Recent tool results. */ toolResults: KdToolResultContract[]; /** Current working set. */ workingSet?: KdWorkingSet; /** User's raw input text for this turn. */ userInput: string; /** Total character budget for the assembled contract. */ totalBudget: number; } // ── PromptContract assembly ────────────────────────────────────────── /** * Assemble a PromptContract from pre-trimmed sections and a budget. * * This function is called by the registry after all sections are collected * and trimmed. It produces the final contract with aggregate metrics. */ export function assembleContract( sections: PromptSection[], totalBudget: number, ): PromptContract { const included = sections.filter((s) => s.include); const totalChars = included.reduce((sum, s) => sum + s.content.length, 0); const traceId = `contract-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; return { sections: included, totalChars, budgetUsed: totalChars, budgetTotal: totalBudget, trimLog: [], traceId, }; } /** * Apply per-section budget limits, then global budget pressure. * * Sections marked `always: true` are never trimmed. * Remaining sections are trimmed lowest-priority-first until the total * character count fits within `totalBudget`. */ export function enforceBudgets( sections: PromptSection[], totalBudget: number, ): { sections: PromptSection[]; trimLog: TrimLogEntry[] } { const trimLog: TrimLogEntry[] = []; // Phase 1: Apply per-section budgets to individual sections. let current = sections.map((section) => { if (!section.include) { trimLog.push({ sectionId: section.id, originalChars: section.content.length, trimmedChars: 0, reason: "excluded", }); return section; } if (section.budget != null && section.content.length > section.budget) { const trimmed = applyPerSectionBudget(section.content, section.budget); trimLog.push({ sectionId: section.id, originalChars: section.content.length, trimmedChars: trimmed.length, reason: "per-section-budget", }); return { ...section, content: trimmed }; } return section; }); // Phase 2: Enforce global budget — trim by priority (highest number first). const totalChars = current.filter((s) => s.include).reduce((sum, s) => sum + s.content.length, 0); if (totalChars > totalBudget) { const sorted = [...current] .filter((s) => s.include && !s.always) .sort((a, b) => b.priority - a.priority); // highest priority number = trimmed first let overBudget = totalChars - totalBudget; for (const section of sorted) { if (overBudget <= 0) break; const idx = current.findIndex((s) => s.id === section.id); if (idx < 0) continue; const original = current[idx].content; const canCut = original.length - 100; // leave at least 100 chars if (canCut <= 0) continue; const cut = Math.min(canCut, overBudget); const trimmed = original.slice(0, original.length - cut) + "\n\n[...已截断;超出预算...]"; trimLog.push({ sectionId: section.id, originalChars: original.length, trimmedChars: trimmed.length, reason: "global-budget", }); current[idx] = { ...current[idx], content: trimmed }; overBudget -= cut; } } return { sections: current, trimLog }; } /** * Apply a per-section character budget by truncating content. * Preserves a truncation marker so consumers know content was cut. */ function applyPerSectionBudget(content: string, budget: number): string { if (content.length <= budget) return content; return `${content.slice(0, budget)}\n\n[...已截断;完整内容读取本地文件...]`; }