Phase 21 foundation — context stability + chunking pipeline

PRD: add Phase 20 (model matrix, wired) and Phase 21 (context stability,
partial). Phase 21 exists because LLM Team hit this exact wall — running
multi-model ranking on large context silently truncated, rankings
degraded, no pipeline caught it. The stable answer: every agent call
goes through a budget check against the model's declared context_window
minus safety_margin, with a declared overflow_policy when the check
fails.

config/models.json:
- context_window + context_budget per tier
- overflow_policies block: summarize_oldest_tool_results_via_t3,
  chunk_lessons_via_cosine_topk, two_pass_map_reduce,
  escalate_to_kimi_k2_1t_or_split_decision
- chunking_cache spec (data/_chunk_cache/, corpus-hash keyed)

agent.ts:
- estimateTokens() chars/4 biased safe ~15%
- CONTEXT_WINDOWS table (fallback; prod reads models.json)
- assertContextBudget() — throws on overflow with exact numbers, can
  bypass with bypass_budget:true for callers with their own policy
- Wired into generate() and generateCloud() so EVERY call is checked

scenario.ts:
- T3 lesson archive to data/_playbook_lessons/*.json (the old
  /vectors/playbook_memory/seed path was silently failing with HTTP 400
  because it requires 'fill: Role xN in City, ST' operation shape)
- loadPriorLessons() at scenario start — filters by city/state match,
  date-sorted, takes top-3
- prior_lessons.json archived per-run (honest signal for A/B)
- guidanceFor() injects up to 2 prior lessons (≤500 chars each) into
  the executor's per-event context
- Retrospective shows explicit "Prior lessons loaded: N" line

Verified: mistral correctly rejects a 150K-char prompt (7532 tokens
over), gpt-oss:120b accepts it with 90K headroom. The enforcement is
in-band on every call now, not an afterthought.

Full chunking service (Rust) remains deferred to the sprint this feeds:
crates/aibridge/src/budget.rs + chunk.rs + storaged/chunk_cache.rs
This commit is contained in:
root
2026-04-20 19:34:44 -05:00
parent 03d723e7e6
commit 6e7ca1830e
4 changed files with 309 additions and 36 deletions
+103 -19
View File
@@ -146,6 +146,50 @@ interface ScenarioContext {
roster: RosterEntry[];
results: EventResult[];
gap_signals: Array<{ event: string; category: string; detail: string }>;
prior_lessons: PriorLesson[];
}
interface PriorLesson {
date: string;
client: string;
cities: string;
states: string;
lesson: string;
events_ok: number;
events_total: number;
file: string;
}
// Load lessons from prior T3 runs — read-back half of the feedback loop.
// Filters to the most relevant by matching ANY city/state with the current
// spec, then takes the 3 newest. Keeps startup cheap; file scan is O(n).
async function loadPriorLessons(spec: ScenarioSpec): Promise<PriorLesson[]> {
try {
const { readdir, readFile } = await import("node:fs/promises");
const dir = join("data", "_playbook_lessons");
const files = await readdir(dir).catch(() => [] as string[]);
if (files.length === 0) return [];
const specCities = new Set(spec.events.map(e => e.city));
const specStates = new Set(spec.events.map(e => e.state));
const parsed: PriorLesson[] = [];
for (const f of files) {
if (!f.endsWith(".json")) continue;
try {
const raw = await readFile(join(dir, f), "utf8");
const rec = JSON.parse(raw);
parsed.push({ ...rec, file: f });
} catch { /* skip malformed */ }
}
const relevant = parsed.filter(p => {
const cities = (p.cities ?? "").split(",");
const states = (p.states ?? "").split(",");
return cities.some(c => specCities.has(c)) || states.some(s => specStates.has(s));
});
relevant.sort((a, b) => (b.date ?? "").localeCompare(a.date ?? ""));
return relevant.slice(0, 3);
} catch {
return [];
}
}
// =================== Default scenario ===================
@@ -493,7 +537,18 @@ CAST(reliability AS DOUBLE) > 0.7.`;
}
})();
return `${schemaLock}\n\nEVENT FOCUS:\n${base}`;
// Prior-lesson hint — surface up to 2 most recent lessons learned from
// T3 overseer runs against this city/state. Terse to avoid diluting the
// prompt. The goal is to pass forward hard-won mistakes, not flood the
// context. This is the read-back half of the T3 feedback loop.
const priorHint = ctx.prior_lessons.length > 0
? `\n\nPRIOR LESSONS (from T3 overseer on past runs in similar cities):\n` +
ctx.prior_lessons.slice(0, 2).map((p, i) =>
`${i + 1}. ${p.date} ${p.client} (${p.cities}): ${p.lesson.replace(/\s+/g, " ").slice(0, 500)}`
).join("\n")
: "";
return `${schemaLock}\n\nEVENT FOCUS:\n${base}${priorHint}`;
}
// =================== Artifact generation ===================
@@ -878,7 +933,8 @@ async function writeRetrospective(ctx: ScenarioContext): Promise<void> {
const lines: string[] = [];
lines.push(`# Scenario retrospective — ${ctx.spec.client}, ${ctx.spec.date}`);
lines.push("");
lines.push(`Executor: \`${EXECUTOR_MODEL}\` Reviewer: \`${REVIEWER_MODEL}\` Draft: \`${DRAFT_MODEL}\` Overview(T3): \`${T3_DISABLED ? "disabled" : OVERVIEW_MODEL}\``);
lines.push(`Executor: \`${EXECUTOR_MODEL}\` Reviewer: \`${REVIEWER_MODEL}\` Draft: \`${DRAFT_MODEL}\` Overview(T3): \`${T3_DISABLED ? "disabled" : OVERVIEW_MODEL + (OVERVIEW_CLOUD ? " (cloud)" : "")}\``);
lines.push(`Prior lessons loaded into executor context: **${ctx.prior_lessons.length}**${ctx.prior_lessons.length > 0 ? " (from " + ctx.prior_lessons.map(p => p.date).join(", ") + ")" : " (baseline — no prior T3 history)"}`);
lines.push("");
// --- Per-event summary ---
@@ -1019,12 +1075,15 @@ async function main() {
const out_dir = join("tests/multi-agent/playbooks", `scenario-${stamp}`);
await mkdir(out_dir, { recursive: true });
const prior_lessons = await loadPriorLessons(spec);
const ctx: ScenarioContext = {
spec,
out_dir,
roster: [],
results: [],
gap_signals: [],
prior_lessons,
};
// Initialize output files
@@ -1033,6 +1092,19 @@ async function main() {
await writeFile(join(out_dir, "dispatch.jsonl"), "");
await writeFile(join(out_dir, "checkpoints.jsonl"), "");
// Archive which prior lessons this run will see, so the retrospective
// can tell whether the T3 feedback loop actually fed back anything.
await writeFile(
join(out_dir, "prior_lessons.json"),
JSON.stringify(prior_lessons, null, 2)
);
if (prior_lessons.length > 0) {
console.log(`▶ prior lessons loaded: ${prior_lessons.length} (from data/_playbook_lessons/)`);
for (const p of prior_lessons) {
console.log(` - ${p.date} ${p.client} (${p.cities}) — ${p.events_ok}/${p.events_total} ok`);
}
}
const checkpoints: OverviewCheckpoint[] = [];
console.log(`▶ scenario: ${spec.client}, ${spec.date}, ${spec.events.length} events`);
@@ -1109,26 +1181,38 @@ async function main() {
);
console.log(`✓ lesson (${lessonSecs}s) → ${join(out_dir, "lesson.md")}`);
// Seed the lesson into playbook_memory for future retrieval. Keep
// the embedded `approach` + `context` terse per feedback_phase19_seed_text.md;
// the rich prose lives in lesson.md and a separate `rationale` field.
// Persist the lesson to data/_playbook_lessons/ so future scenarios
// can read it verbatim at startup. The /vectors/playbook_memory/seed
// endpoint rejects operations that don't match the `fill: Role xN
// in City, ST` regex (enforced in crates/vectord/src/service.rs),
// so embedding-based retrieval of cross-day lessons isn't wired.
// File-based read-back is durable and explicit — future scenarios
// pull from the lessons dir at startup and include top-N in the
// executor's system context.
try {
const kinds = [...new Set(ctx.spec.events.map(e => e.kind))].join("+");
const cities = [...new Set(ctx.spec.events.map(e => e.city))].slice(0, 3).join(",");
await fetch(`${GATEWAY}/vectors/playbook_memory/seed`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
operation: `cross-day-lesson-${ctx.spec.date}`,
approach: `${kinds} day in ${cities}`,
context: `${ctx.spec.client} ${ctx.spec.date}`,
rationale: lesson.slice(0, 2000),
endorsed_names: [],
append: true,
}),
});
const states = [...new Set(ctx.spec.events.map(e => e.state))].slice(0, 3).join(",");
const lessonsDir = join("data", "_playbook_lessons");
await mkdir(lessonsDir, { recursive: true });
const lessonRec = {
date: ctx.spec.date,
client: ctx.spec.client,
cities,
states,
events_total: ctx.spec.events.length,
events_ok: ctx.results.filter(r => r.ok).length,
checkpoint_count: checkpoints.length,
model: OVERVIEW_MODEL,
cloud: OVERVIEW_CLOUD,
lesson: lesson.trim(),
checkpoints: checkpoints.map(c => ({ after: c.after_event, risk: c.risk, hint: c.hint })),
created_at: new Date().toISOString(),
};
const fname = `${ctx.spec.date}_${ctx.spec.client.replace(/\s+/g, "_")}_${Date.now()}.json`;
await writeFile(join(lessonsDir, fname), JSON.stringify(lessonRec, null, 2));
console.log(` lesson archived → ${join(lessonsDir, fname)}`);
} catch (e) {
console.log(` (lesson seed skipped: ${(e as Error).message})`);
console.log(` (lesson archive skipped: ${(e as Error).message})`);
}
}
}