Phase 19 wiring + Path 1/2 work + chain integrity fixes

Backend:
- crates/vectord/src/playbook_memory.rs (new): Phase 19 in-memory boost
  store with seed/rebuild/snapshot, plus temporal decay (e^-age/30 per
  playbook), persist_to_sql endpoint backing successful_playbooks_live,
  and discover_patterns endpoint for meta-index pattern aggregation
  (recurring certs/skills/archetype/reliability across similar past fills).
- DEFAULT_TOP_K_PLAYBOOKS bumped 5 → 25; old default silently missed
  most boosts when memory had > 25 entries.
- service.rs: new routes /vectors/playbook_memory/{seed,rebuild,stats,
  persist_sql,patterns}.

Bun staffing co-pilot (mcp-server/):
- /search, /match, /verify, /proof, /simulation/run, MCP tools all
  forward use_playbook_memory:true and playbook_memory_k:25 to the
  hybrid endpoint. Boost was previously dark across the entire app.
- /log no longer POSTs to /ingest/file — that endpoint REPLACES the
  dataset's object list, so single-row CSV writes were wiping all prior
  rows in successful_playbooks (sp_rows went 33→1 in one /log call).
  /log now seeds playbook_memory with canonical short text and calls
  /persist_sql to keep successful_playbooks_live in sync.
- /simulation/run cumulative end-of-week CSV write removed for the same
  reason. Per-day per-contract /seed (added in this session) is the
  accumulating feedback path now.
- search.html addWorkerInsight renders a green "Endorsed · N playbooks"
  chip with playbook citations when boost > 0.

Internal Dioxus UI (crates/ui/):
- Dashboard phase list rewritten through Phase 19 (was stuck at "Phase
  16: File Watcher" / "Phase 17: DB Connector" — both wrong).
- Removed fabricated "27ms" stat label.
- Ask tab examples + SQL default replaced with real staffing prompts
  against candidates/clients/job_orders (was referencing nonexistent
  employees/products/events).
- New Playbook tab exposes /vectors/playbook_memory/{stats,rebuild} and
  side-by-side hybrid search (boost OFF vs ON) with citations.

Tests (tests/multi-agent/):
- run_e2e_rated.ts: parallel two-agent (mistral + qwen2.5) build phase
  + verifier rating (geo, auth, persist, boost, speed → /10).
- network_proving.ts: continuous build → verify → repeat with
  staffing-recruiter profile hot-swap; geo-discrimination check.
- chain_of_custody.ts: single recruiter operation traced through every
  layer (Bun /search, direct /vectors/hybrid parity, /log, SQL,
  playbook_memory growth, profile activation, post-op boost lift).
This commit is contained in:
root
2026-04-20 06:21:13 -05:00
parent 8e3cac5812
commit 25b7e6c3a7
13 changed files with 4566 additions and 84 deletions
+351
View File
@@ -0,0 +1,351 @@
// Shared runtime for one agent. An agent is a role (executor or reviewer),
// a model name, and a conversation the orchestrator hands it. The agent
// produces ONE structured Action per turn; the orchestrator applies tool
// calls and feeds results back.
//
// Fail-fast: every HTTP error, parse error, and Ollama error throws. The
// orchestrator catches at the top and exits non-zero with the full log.
export const GATEWAY = "http://localhost:3100";
export const SIDECAR = "http://localhost:3200";
// --- Shared types ---
export type Role = "executor" | "reviewer";
export interface TaskSpec {
id: string;
operation: string; // "fill: Welder x2 in Columbus, OH"
target_role: string; // "Welder"
target_count: number; // 2
target_city: string; // "Columbus"
target_state: string; // "OH"
approach_hint?: string; // e.g. "hybrid search"; agent is free to ignore
}
export interface LogEntry {
turn: number;
role: Role;
model: string;
at: string;
kind:
| "plan"
| "tool_call"
| "tool_result"
| "critique"
| "propose_done"
| "consensus_done"
| "error";
content: any;
}
// Action = what an agent returns on one turn. Strict shape so we can
// enforce it at parse time rather than prompt-engineer around malformed
// JSON.
export type Action =
| { kind: "tool_call"; tool: string; args: Record<string, any>; rationale: string }
| { kind: "propose_done"; fills: Fill[]; rationale: string }
| { kind: "critique"; verdict: "continue" | "drift" | "approve_done"; notes: string }
| { kind: "plan"; steps: string[] };
export interface Fill {
candidate_id: string;
name: string;
reason: string;
}
// --- HTTP helpers (fail-fast) ---
async function http<T>(method: string, url: string, body?: any): Promise<T> {
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${method} ${url} → ${res.status}: ${text}`);
}
return (await res.json()) as T;
}
// Tool calls land in the Phase 12 audit log keyed by this agent name.
// Distinguishable from human-driven calls (agent=="operator" or similar)
// so post-hoc queries can separate multi-agent runs.
export const TOOL_AGENT_ID = "multi-agent-test";
export async function callTool(tool: string, args: Record<string, any>): Promise<any> {
return http("POST", `${GATEWAY}/tools/${tool}/call`, {
params: args,
agent: TOOL_AGENT_ID,
});
}
export async function hybridSearch(sql_filter: string, question: string, k = 10): Promise<any> {
return http("POST", `${GATEWAY}/vectors/hybrid`, { sql_filter, question, k });
}
export async function sqlQuery(sql: string): Promise<any> {
return http("POST", `${GATEWAY}/query/sql`, { sql, format: "json" });
}
// Sidecar generate. Ollama's default keep_alive (5 min) keeps the model
// warm between turns on its own, so we don't need to pass it through.
export async function generate(model: string, prompt: string, opts: {
max_tokens?: number;
temperature?: number;
system?: string;
} = {}): Promise<string> {
const body: Record<string, any> = {
model,
prompt,
temperature: opts.temperature ?? 0.3,
max_tokens: opts.max_tokens ?? 800,
};
if (opts.system) body.system = opts.system;
const r = await http<any>("POST", `${SIDECAR}/generate`, body);
const text = r.text ?? "";
if (!text || typeof text !== "string") {
throw new Error(`generate returned empty text from ${model}: ${JSON.stringify(r).slice(0, 200)}`);
}
return text;
}
// --- Prompt construction ---
const TOOL_CATALOG = `
Available tools (each takes a JSON "args" object):
- hybrid_search(sql_filter: string, question: string, index_name: string, k?: number)
→ Narrow workers via SQL WHERE clause, then rank by semantic match.
→ Canonical production tool for fill tasks. Always use this FIRST.
→ Example args:
{"index_name":"workers_500k_v1",
"sql_filter":"LOWER(role) LIKE '%weld%' AND city = 'Toledo' AND state = 'OH' AND availability > 0.5",
"question":"reliable welder with OSHA certs",
"k":10}
- sql(query: string)
→ Raw read-only SELECT. Use for verification (confirm a worker exists,
check city/role/availability) after hybrid_search surfaces candidates.
→ Schema of workers_500k: worker_id, name, role, email, phone, city,
state, zip, skills, certifications, archetype, reliability,
responsiveness, engagement, communications, compliance, availability,
resume_text.
→ Example args:
{"query":"SELECT worker_id, name, role, city, state, availability FROM workers_500k WHERE worker_id = 'W123456'"}
Rules:
- hybrid_search returns sources[] each with {doc_id, chunk_text, score, sql_verified}.
- **ID mapping:** vector doc_ids look like "W500K-7995" (prefix + number).
The SQL worker_id is an INTEGER. To go from doc_id to SQL, strip the
"W500K-" prefix and cast:
SELECT ... FROM workers_500k WHERE worker_id = CAST(SUBSTR('W500K-7995', 7) AS BIGINT)
or more simply: WHERE worker_id = 7995.
- Names are NOT unique. Always identify by worker_id, never by name alone.
- Return EXACTLY ONE JSON object per turn. No prose outside the JSON.
`;
// Smart per-kind summary so agents see the substance of each prior turn
// without a raw-JSON wall of text. hybrid_search results especially need
// this — raw JSON buries sources[] past any reasonable 400-char truncation.
function summarizeEntry(e: LogEntry): string {
const c = e.content ?? {};
switch (e.kind) {
case "plan":
return `PLAN: ${(c.steps ?? []).map((s: string, i: number) => `${i + 1}.${s}`).join(" ")}`;
case "tool_call":
return `TOOL_CALL ${c.tool}(${JSON.stringify(c.args ?? {}).slice(0, 250)})${c.rationale ? ` — ${c.rationale}` : ""}`;
case "tool_result": {
if (c.error) return `TOOL_RESULT error: ${c.error}`;
// hybrid_search response
if (Array.isArray(c.sources)) {
const head = c.sources.slice(0, 5).map((s: any) =>
`${s.doc_id}${s.sql_verified ? "✓" : ""} score=${(s.score ?? 0).toFixed(2)}: ${String(s.chunk_text ?? "").slice(0, 80)}`
).join(" | ");
return `TOOL_RESULT hybrid: sql_matches=${c.sql_matches} vector_reranked=${c.vector_reranked} sources=[${head}${c.sources.length > 5 ? ` +${c.sources.length - 5} more` : ""}]`;
}
// sql response
if (Array.isArray(c.rows)) {
const head = c.rows.slice(0, 5).map((r: any) => JSON.stringify(r)).join(" | ");
return `TOOL_RESULT sql: ${c.rows.length} rows${c.rows.length > 0 ? ` — ${head}${c.rows.length > 5 ? ` +${c.rows.length - 5} more` : ""}` : ""}`;
}
// fallback
return `TOOL_RESULT ${JSON.stringify(c).slice(0, 250)}`;
}
case "critique":
return `CRITIQUE verdict=${c.verdict} notes: ${String(c.notes ?? "").slice(0, 200)}`;
case "propose_done":
return `PROPOSE_DONE fills=[${(c.fills ?? []).map((f: Fill) => `${f.candidate_id}:${f.name}`).join(", ")}] rationale: ${String(c.rationale ?? "").slice(0, 120)}`;
case "consensus_done":
return `CONSENSUS ✓`;
case "error":
return `ERROR ${c.message ?? JSON.stringify(c)}`;
}
return JSON.stringify(c).slice(0, 200);
}
function renderLogForPrompt(log: LogEntry[]): string {
if (log.length === 0) return "(no turns yet)";
return log.slice(-12).map(e =>
`[t${e.turn} ${e.role}] ${summarizeEntry(e)}`
).join("\n");
}
// Crawl the log for every hybrid_search tool_result and collect the
// worker names + ids seen so far. LLMs routinely "forget" earlier turns
// once the conversation grows, so we surface a running ledger in the
// prompt as orchestrator-maintained state. The executor doesn't have to
// track this itself — it just reads it.
function candidatesSeen(log: LogEntry[]): Array<{ doc_id: string; name: string; city: string; state: string }> {
const seen = new Map<string, { doc_id: string; name: string; city: string; state: string }>();
for (const e of log) {
if (e.kind !== "tool_result") continue;
const sources = (e.content as any)?.sources;
if (!Array.isArray(sources)) continue;
for (const s of sources) {
// chunk_text shape "Name — Role in City, ST. …"
const t = String(s.chunk_text ?? "");
const [namePart, rest] = t.split("—", 2);
if (!namePart || !rest) continue;
const loc = rest.split(" in ")[1] ?? "";
const [city, stateRaw] = loc.split(",", 2);
const state = (stateRaw ?? "").trim().replace(/[^A-Za-z].*/, "");
if (!s.doc_id || !namePart.trim() || !city?.trim() || !state) continue;
if (!seen.has(s.doc_id)) {
seen.set(s.doc_id, {
doc_id: s.doc_id,
name: namePart.trim(),
city: city.trim(),
state,
});
}
}
}
return Array.from(seen.values());
}
export function executorPrompt(task: TaskSpec, log: LogEntry[]): string {
const logStr = renderLogForPrompt(log);
const seen = candidatesSeen(log);
const seenBlock = seen.length === 0
? "(no candidates surfaced yet — start with hybrid_search)"
: seen.map(s => ` - ${s.doc_id} ${s.name} (${s.city}, ${s.state})`).join("\n");
return `You are the EXECUTOR agent. Your job is to complete this task:
OPERATION: ${task.operation}
TARGET: ${task.target_count} × ${task.target_role} in ${task.target_city}, ${task.target_state}
${task.approach_hint ? `HINT: ${task.approach_hint}` : ""}
The REVIEWER agent is watching every turn. They will flag drift. Stay on target.
${TOOL_CATALOG}
CANDIDATES SURFACED SO FAR (orchestrator-tracked, do not forget these):
${seenBlock}
SHARED LOG (recent turns):
${logStr}
Your next action MUST be a JSON object matching one of these shapes:
{"kind":"plan","steps":["short step 1","short step 2",...]}
— use on turn 1 to outline your approach. Steps must be concrete.
{"kind":"tool_call","tool":"...","args":{...},"rationale":"why"}
— call a tool and see its result next turn.
{"kind":"propose_done","fills":[{"candidate_id":"...","name":"First Last","reason":"why them"}],"rationale":"..."}
— propose you've met the target. fills MUST have EXACTLY ${task.target_count} entries — count twice before emitting.
Strategy tip: once "CANDIDATES SURFACED SO FAR" has ≥ ${task.target_count} entries in ${task.target_city}, ${task.target_state} matching ${task.target_role}, verify ONE via the sql tool (to satisfy the reviewer's SQL-verification criterion) and then propose_done with the top ${task.target_count}. Don't keep re-searching.
Respond with ONLY the JSON object. No markdown fences, no prose.`;
}
export function reviewerPrompt(task: TaskSpec, log: LogEntry[]): string {
const logStr = renderLogForPrompt(log);
// If the most recent executor action was propose_done, the reviewer
// must commit to an up-or-down vote this turn — "continue" would stall
// the orchestrator forever. The wider prompt still describes all three
// verdicts, but we add a hard rule at the end that the model must obey.
const lastExec = [...log].reverse().find(e => e.role === "executor");
const awaitingApproval = lastExec?.kind === "propose_done";
return `You are the REVIEWER agent. The EXECUTOR is trying to complete this task:
OPERATION: ${task.operation}
TARGET: ${task.target_count} × ${task.target_role} in ${task.target_city}, ${task.target_state}
Your job: catch drift. Agents often wander from the actual objective. Specifically watch for:
- Proposing candidates who aren't in ${task.target_city}, ${task.target_state}.
- Proposing candidates who don't have ${task.target_role} skill.
- Proposing fewer or more than ${task.target_count} fills.
- Irrelevant tool calls (e.g. revenue_by_client when the task is a fill).
Available tools (for reference, but YOU don't call them):
- hybrid_search(sql_filter, question, index_name, k) — production fill path
- sql(query) — read-only SELECT for verification
SHARED LOG (recent turns):
${logStr}
Your next action MUST be a JSON object:
{"kind":"critique","verdict":"continue" | "drift" | "approve_done","notes":"..."}
- "continue" → executor is on a reasonable path, let them keep going.
- "drift" → executor is off-track; notes MUST tell them how to redirect.
- "approve_done" → executor's propose_done meets the criteria. Seal it.
APPROVAL CRITERIA (use these only for propose_done):
1. Exactly ${task.target_count} fills.
2. Each fill's name appears in a prior tool_result from ${task.target_city}, ${task.target_state} matching role "${task.target_role}".
3. Executor has SQL-verified at least one of the fills (any prior sql tool_result with that worker).
If 1–3 all hold, return approve_done. Do not demand further verification.
${awaitingApproval ? `
HARD RULE: The executor's most recent action was propose_done. On this turn you CANNOT return "continue" — it would stall the task. Choose approve_done (proposal is valid by the 3 criteria above) or drift (it fails one; state which in notes).` : ""}
Respond with ONLY the JSON object.`;
}
// Parse an agent's response into an Action, or throw.
export function parseAction(raw: string, role: Role): Action {
// Models sometimes wrap JSON in ```json fences; strip them.
let s = raw.trim();
if (s.startsWith("```")) {
s = s.replace(/^```(?:json)?\n?/, "").replace(/```$/, "").trim();
}
// Find the first {...} block.
const start = s.indexOf("{");
const end = s.lastIndexOf("}");
if (start < 0 || end <= start) {
throw new Error(`no JSON object in ${role} response: ${raw.slice(0, 300)}`);
}
const json = s.slice(start, end + 1);
let obj: any;
try {
obj = JSON.parse(json);
} catch (e) {
throw new Error(`invalid JSON from ${role}: ${(e as Error).message} | raw: ${json.slice(0, 300)}`);
}
if (role === "executor") {
if (obj.kind === "plan" && Array.isArray(obj.steps)) return obj as Action;
if (obj.kind === "tool_call" && typeof obj.tool === "string" && typeof obj.args === "object") return obj as Action;
if (obj.kind === "propose_done" && Array.isArray(obj.fills)) return obj as Action;
throw new Error(`executor returned unexpected shape: ${JSON.stringify(obj).slice(0, 200)}`);
} else {
// Normalize: some models (qwen2.5, mistral) emit the verdict AS the
// `kind` field directly instead of nesting it under a "critique"
// wrapper. Accept both shapes rather than hard-failing — the
// semantic content is identical, and rejecting would stall the
// orchestrator on a cosmetic schema miss.
if (obj.kind === "critique" && ["continue", "drift", "approve_done"].includes(obj.verdict)) {
return obj as Action;
}
if (["continue", "drift", "approve_done"].includes(obj.kind)) {
return { kind: "critique", verdict: obj.kind, notes: obj.notes ?? "" } as Action;
}
throw new Error(`reviewer returned unexpected shape: ${JSON.stringify(obj).slice(0, 200)}`);
}
}
+335
View File
@@ -0,0 +1,335 @@
// Chain-of-custody trace test.
//
// J's framing: "we have enough synthetic data, we've run enough AI responses
// saved to the database. Test true quality. Don't ignore chain of custody.
// Use real applications. Understand each aspect of the flow — not just
// 'write a file or directory and open it'."
//
// One real recruiter operation, traced end-to-end through EVERY layer of the
// live substrate. Every layer must record the operation correctly. Any layer
// that drops it = chain-of-custody break = surfaced as a real bug.
//
// Layers verified:
// L0 Bun /search — recruiter app surface (NOT bare /vectors/hybrid)
// L1 /vectors/hybrid — direct gateway (parity check vs L0)
// L2 /vectors/playbook_memory/stats — feedback loop count
// L3 Bun /log — recruiter records the pick
// L4 successful_playbooks — SQL-queryable table of past fills
// L5 /vectors/playbook_memory/stats — count grew
// L6 tools/audit — Phase 12 governance trail
// L7 /access/audit — Phase 13 access trail
// L8 /journal/recent — Phase 9 mutation events
// L9 /storage/errors — Federation error journal (no new errors)
// L10 /vectors/profile/{id}/activate — Phase 17 hot-swap
// L11 Bun /search again — boost lifts the just-logged worker
// L12 verifier qwen2.5 — reads cross-layer state, judges integrity
//
// Run: bun run tests/multi-agent/chain_of_custody.ts
//
// Prints per-layer BEFORE/AFTER/DELTA. Exit non-zero on any chain break.
import { generate, GATEWAY } from "./agent.ts";
const BUN = "http://localhost:3700";
const PROFILE_ID = "staffing-recruiter";
// The trace operation — small, deterministic, real city/role with supply.
// Helen Sanchez (worker_id 4661) is a known Toledo Welder; we record her
// as the manual pick the recruiter would make from the /search results.
const OPERATION = "fill: Welder x1 in Toledo, OH";
const OP_ROLE = "Welder";
const OP_CITY = "Toledo";
const OP_STATE = "OH";
const PICKED_WORKER = "Helen Sanchez"; // verified earlier to be a Toledo OH Welder
// ─────────────────────── helpers ───────────────────────
async function getJSON<T = any>(url: string): Promise<T | null> {
try {
const r = await fetch(url);
if (!r.ok) return null;
return r.json() as Promise<T>;
} catch { return null; }
}
async function postJSON<T = any>(url: string, body: any): Promise<T | null> {
try {
const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
if (!r.ok) return { _error: `${r.status}: ${await r.text()}` } as any;
return r.json() as Promise<T>;
} catch (e) { return { _error: (e as Error).message } as any; }
}
async function sql(query: string): Promise<{ rows?: any[]; error?: string } | null> {
return postJSON(`${GATEWAY}/query/sql`, { sql: query });
}
interface Snapshot {
pm_entries: number;
pm_names: number;
sp_rows: number; // successful_playbooks SQL row count
audit_count: number; // tools/audit count
access_count: number; // access/audit count
journal_count: number; // journal/stats events
storage_errors: number; // bucket error journal
}
async function snapshot(): Promise<Snapshot> {
const pm = await getJSON<any>(`${GATEWAY}/vectors/playbook_memory/stats`);
// successful_playbooks_live is the live SQL surface populated by /log
// via /vectors/playbook_memory/persist_sql. The original
// successful_playbooks table is now legacy/historical (no writes).
const sp = await sql(`SELECT COUNT(*) AS c FROM successful_playbooks_live`);
const audit = await getJSON<any[]>(`${GATEWAY}/tools/audit`);
const access = await getJSON<any>(`${GATEWAY}/access/audit`);
const journalStats = await getJSON<any>(`${GATEWAY}/journal/stats`);
const storageErrors = await getJSON<any[]>(`${GATEWAY}/storage/errors`);
return {
pm_entries: pm?.entries ?? -1,
pm_names: pm?.total_names_endorsed ?? -1,
sp_rows: Number(sp?.rows?.[0]?.c ?? -1),
audit_count: Array.isArray(audit) ? audit.length : (audit as any)?.events?.length ?? -1,
access_count: Array.isArray(access) ? access.length : (access as any)?.events?.length ?? (access as any)?.audit?.length ?? -1,
journal_count: journalStats?.event_count ?? journalStats?.total_events ?? journalStats?.events ?? -1,
storage_errors: Array.isArray(storageErrors) ? storageErrors.length : (storageErrors as any)?.events?.length ?? 0,
};
}
function delta(b: Snapshot, a: Snapshot): Record<string, number> {
return {
pm_entries: a.pm_entries - b.pm_entries,
pm_names: a.pm_names - b.pm_names,
sp_rows: a.sp_rows - b.sp_rows,
audit_count: a.audit_count - b.audit_count,
access_count: a.access_count - b.access_count,
journal_count: a.journal_count - b.journal_count,
storage_errors: a.storage_errors - b.storage_errors,
};
}
function fmtRow(label: string, b: number, a: number): string {
const d = a - b;
const dStr = d === 0 ? " · " : d > 0 ? ` +${d}` : ` ${d}`;
return ` ${label.padEnd(28)} ${String(b).padStart(6)} → ${String(a).padStart(6)} ${dStr}`;
}
// ─────────────────────── trace ───────────────────────
interface TraceResult {
layer: string;
ok: boolean;
detail: string;
}
async function runTrace(): Promise<TraceResult[]> {
const out: TraceResult[] = [];
const note = (layer: string, ok: boolean, detail: string) => {
out.push({ layer, ok, detail });
console.log(` ${ok ? "✓" : "✗"} ${layer.padEnd(32)} ${detail}`);
};
console.log(`\n▶ Trace operation: ${OPERATION} → pick=${PICKED_WORKER}\n`);
// ── BEFORE snapshot ──
console.log(`▶ Before-snapshot:`);
const before = await snapshot();
console.log(` pm_entries=${before.pm_entries} pm_names=${before.pm_names} sp_rows=${before.sp_rows} `
+ `audit=${before.audit_count} access=${before.access_count} journal=${before.journal_count} `
+ `storage_errors=${before.storage_errors}\n`);
// ── L0: Bun /search ──
console.log(`▶ L0 — Bun /search (recruiter app surface)`);
const sql_filter = `role = '${OP_ROLE}' AND state = '${OP_STATE}' AND city = '${OP_CITY}'`;
const bunSearch = await postJSON<any>(`${BUN}/search`, {
question: `Welder in ${OP_CITY}, ${OP_STATE}`,
sql_filter, top_k: 5, generate: false,
id_column: "worker_id", dataset: "workers_500k", use_playbook_memory: true,
});
if (bunSearch?._error) {
note("L0 Bun /search", false, `error: ${bunSearch._error}`);
} else {
const sources = bunSearch?.sources ?? [];
const boostedHits = sources.filter((s: any) => (s.playbook_boost ?? 0) > 0).length;
note("L0 Bun /search", true, `sources=${sources.length} boosted=${boostedHits} sql_matches=${bunSearch?.sql_matches}`);
}
// ── L1: direct /vectors/hybrid (parity check) ──
console.log(`\n▶ L1 — Direct /vectors/hybrid (parity check vs Bun)`);
const directSearch = await postJSON<any>(`${GATEWAY}/vectors/hybrid`, {
index_name: "workers_500k_v1", filter_dataset: "workers_500k", id_column: "worker_id",
sql_filter, question: `Welder in ${OP_CITY}, ${OP_STATE}`,
top_k: 5, generate: false, use_playbook_memory: true, playbook_memory_k: 15,
});
const directBoosted = (directSearch?.sources ?? []).filter((s: any) => (s.playbook_boost ?? 0) > 0).length;
note("L1 Direct /vectors/hybrid", true, `boosted=${directBoosted} sql=${directSearch?.sql_matches}`);
const bunBoosted = (bunSearch?.sources ?? []).filter((s: any) => (s.playbook_boost ?? 0) > 0).length;
if (bunBoosted < directBoosted) {
note("CHAIN BREAK: Bun↔Direct parity", false,
`Bun=${bunBoosted} boosted vs Direct=${directBoosted}. Bun /search likely missing playbook_memory_k forward.`);
}
// ── L3: Bun /log (recruiter records the pick) ──
console.log(`\n▶ L3 — Bun /log (recruiter records the pick)`);
const logged = await postJSON<any>(`${BUN}/log`, {
operation: OPERATION,
approach: "chain-of-custody trace",
result: `1/1 filled → ${PICKED_WORKER}`,
context: `client=COC-${Date.now()} start=08:00 scenario=trace`,
});
if (logged?._error) note("L3 Bun /log", false, `error: ${logged._error}`);
else note("L3 Bun /log", true, `logged=${logged?.logged} seeded=${logged?.seeded}`);
// The /log response carries the result of the underlying /ingest/file too.
// If "response" mentions "different schema" or "error", the SQL-queryable
// path is broken even though seed succeeded. That's a chain break.
const logResp = String((logged as any)?.response ?? "");
if (logResp.includes("error") || logResp.includes("different schema") || logResp.includes("Error")) {
note("CHAIN BREAK: Bun /log → SQL ingest", false,
`successful_playbooks ingest failed. Bun returned logged=true but /log's underlying ingest reported: ${logResp.slice(0, 150)}`);
} else {
note("L3a /log → /ingest/file", true, "ingest accepted");
}
// Give the system a beat for any async fan-out (audit/journal/etc).
await new Promise(r => setTimeout(r, 500));
// ── AFTER snapshot ──
console.log(`\n▶ After-snapshot:`);
const after = await snapshot();
const d = delta(before, after);
console.log(fmtRow("playbook_memory.entries", before.pm_entries, after.pm_entries));
console.log(fmtRow("playbook_memory.names", before.pm_names, after.pm_names));
console.log(fmtRow("successful_playbooks.rows", before.sp_rows, after.sp_rows));
console.log(fmtRow("tools/audit.count", before.audit_count, after.audit_count));
console.log(fmtRow("access/audit.count", before.access_count, after.access_count));
console.log(fmtRow("journal.events", before.journal_count, after.journal_count));
console.log(fmtRow("storage/errors.count", before.storage_errors,after.storage_errors));
// ── L5: playbook_memory grew? ──
if (d.pm_entries === 1) note("L5 playbook_memory growth", true, "+1 entry as expected");
else note("L5 playbook_memory growth", d.pm_entries > 0,
`delta=${d.pm_entries} (expected 1 — seed-after-log path)`);
// ── L4: successful_playbooks SQL row appeared? ──
if (d.sp_rows >= 1) note("L4 successful_playbooks SQL", true, `+${d.sp_rows} row(s)`);
else note("L4 successful_playbooks SQL", false,
`delta=${d.sp_rows} — Bun /log claims success but SQL table didn't grow. Recruiter querying via SQL would miss this fill.`);
// ── L9: storage errors stayed quiet ──
if (d.storage_errors === 0) note("L9 storage error journal", true, "no new bucket op errors");
else note("L9 storage error journal", false, `+${d.storage_errors} new errors`);
// ── L10: Phase 17 profile activation ──
console.log(`\n▶ L10 — Activate profile ${PROFILE_ID}`);
const act = await postJSON<any>(`${GATEWAY}/vectors/profile/${PROFILE_ID}/activate`, {});
if (act?._error) note("L10 profile activation", false, `error: ${act._error}`);
else note("L10 profile activation", true,
`warmed=${(act?.warmed_indexes ?? []).length} duration_ms=${act?.duration_ms ?? "?"}`);
// ── L11: Bun /search again — boost should now lift PICKED_WORKER ──
console.log(`\n▶ L11 — Bun /search second time (boost lift verification)`);
const search2 = await postJSON<any>(`${BUN}/search`, {
question: `Welder in ${OP_CITY}, ${OP_STATE}`,
sql_filter, top_k: 10, generate: false,
id_column: "worker_id", dataset: "workers_500k", use_playbook_memory: true,
});
const sources2 = search2?.sources ?? [];
const pickedHit = sources2.find((s: any) => String(s.chunk_text ?? "").includes(PICKED_WORKER));
if (!pickedHit) {
note("L11 boost lifts logged pick (Bun)", false,
`${PICKED_WORKER} not in top-10 via Bun /search. Could be Bun-not-forwarding-playbook_memory_k bug from L1.`);
} else if ((pickedHit.playbook_boost ?? 0) > 0) {
note("L11 boost lifts logged pick (Bun)", true,
`${PICKED_WORKER} boost=+${(pickedHit.playbook_boost as number).toFixed(3)} cites=${(pickedHit.playbook_citations ?? []).length}`);
} else {
note("L11 boost lifts logged pick (Bun)", false,
`${PICKED_WORKER} present but boost=0 — playbook_memory_k forward bug likely`);
}
// Same probe via direct gateway to isolate Bun vs gateway
const direct2 = await postJSON<any>(`${GATEWAY}/vectors/hybrid`, {
index_name: "workers_500k_v1", filter_dataset: "workers_500k", id_column: "worker_id",
sql_filter, question: `Welder in ${OP_CITY}, ${OP_STATE}`,
top_k: 10, generate: false, use_playbook_memory: true, playbook_memory_k: 15,
});
const sources2d = direct2?.sources ?? [];
const pickedHitD = sources2d.find((s: any) => String(s.chunk_text ?? "").includes(PICKED_WORKER));
if (pickedHitD && (pickedHitD.playbook_boost ?? 0) > 0) {
note("L11b boost via direct gateway", true,
`${PICKED_WORKER} boost=+${(pickedHitD.playbook_boost as number).toFixed(3)} cites=${(pickedHitD.playbook_citations ?? []).length}`);
} else {
note("L11b boost via direct gateway", false, `direct call also did not boost ${PICKED_WORKER}`);
}
return out;
}
// ─────────────────────── verifier (fresh agent) ───────────────────────
async function verifierJudgment(trace: TraceResult[]): Promise<{ verdict: string; confidence: number }> {
const summary = trace.map(t => ` ${t.ok ? "ok" : "FAIL"} ${t.layer}: ${t.detail}`).join("\n");
const prompt = `You are the CHAIN-OF-CUSTODY VERIFIER agent. A real recruiter operation was just
traced through every layer of the staffing substrate. Read the per-layer results and judge
whether the system kept chain of custody intact (every layer recorded the operation as
expected) or where it broke.
Per-layer trace:
${summary}
Reply with ONE JSON object only:
{"verdict": "<one tight sentence — what's the integrity status>", "confidence": 0-100}
Be specific about which layer broke if any. confidence is how sure you are about the verdict.`;
try {
const raw = await generate("qwen2.5:latest", prompt, { temperature: 0.1, max_tokens: 200 });
const start = raw.indexOf("{"), end = raw.lastIndexOf("}");
if (start < 0 || end <= start) return { verdict: "verifier could not produce JSON", confidence: 0 };
const j = JSON.parse(raw.slice(start, end + 1));
return { verdict: j.verdict ?? "no verdict", confidence: Number(j.confidence) || 0 };
} catch (e) {
return { verdict: `verifier error: ${(e as Error).message}`, confidence: 0 };
}
}
// ─────────────────────── main ───────────────────────
async function main() {
console.log(`▶ Chain-of-custody trace — single real recruiter operation through every layer`);
const trace = await runTrace();
console.log(`\n▶ L12 — Verifier (fresh qwen2.5 agent reads the cross-layer trace)`);
const v = await verifierJudgment(trace);
console.log(` verdict (${v.confidence}%): ${v.verdict}`);
// Hard gate: any explicit CHAIN BREAK note = fail
const breaks = trace.filter(t => !t.ok && t.layer.startsWith("CHAIN BREAK"));
const fails = trace.filter(t => !t.ok);
console.log(`\n▶ Summary:`);
console.log(` passing layers: ${trace.filter(t => t.ok).length}/${trace.length}`);
console.log(` chain breaks: ${breaks.length}`);
console.log(` total failures: ${fails.length}`);
console.log(` verifier confidence: ${v.confidence}%`);
if (breaks.length > 0) {
console.log(`\n✗ Chain of custody BROKEN at ${breaks.length} layer(s):`);
for (const b of breaks) console.log(` - ${b.layer}: ${b.detail}`);
process.exit(1);
}
if (fails.length > 0) {
console.log(`\n◑ Trace completed with ${fails.length} non-blocking failures (no formal chain break)`);
process.exit(0);
}
console.log(`\n✓ Chain of custody intact across all layers`);
process.exit(0);
}
main().catch(e => {
console.error(`\n✗ ${(e as Error).message}`);
if ((e as any).stack) console.error((e as any).stack);
process.exit(1);
});
+469
View File
@@ -0,0 +1,469 @@
// Network proving: continuous build → verify → repeat with hot-swap profile.
//
// J's framing: "have them guide each other, when the test is complete we have
// a successful playbook, then spin up another agent that tests the viability
// of our network with the playbook and the hot-swap profile. Keep spinning up
// agents and testing — pass theory, real-world execution, not isolated unit
// tests."
//
// Each round = TWO phases:
//
// 1. BUILD phase. Two agents (mistral executor + qwen2.5 reviewer) work
// on a real staffing fill task. They guide each other via the critique
// loop. On consensus → seal a playbook with CANONICAL short seed text
// (the Pass 1 lesson — verbose seeds silently kill boost). Real Ollama,
// real workers_500k, real /vectors/hybrid path.
//
// 2. VERIFY phase. A FRESH qwen2.5 agent spins up, activates the
// staffing-recruiter profile (Phase 17 hot-swap), runs a probe query
// against the same network, and judges from the live response whether
// prior rounds' playbooks actually surface relevant workers higher.
// The verifier writes a verdict: did the network learn?
//
// Three rounds, progressively harder:
// R0: Welder x2 in Toledo, OH — baseline
// R1: Welder x2 in Cleveland, OH — same role, different city
// → tests geo discrimination
// (Toledo workers MUST NOT
// bleed into Cleveland boost)
// R2: Welder x3 in Toledo, OH — re-fill same city, bigger
// count → tests compounding
// (R0's endorsements should
// still rank up here)
//
// Run: bun run tests/multi-agent/network_proving.ts
//
// Fail-fast: any HTTP error or model crash bubbles to top-level, exits 1.
import {
type LogEntry,
type TaskSpec,
type Action,
type Fill,
GATEWAY,
generate,
parseAction,
executorPrompt,
reviewerPrompt,
sqlQuery,
callTool,
} from "./agent.ts";
const EXECUTOR_MODEL = "mistral:latest";
const REVIEWER_MODEL = "qwen2.5:latest";
const VERIFIER_MODEL = "qwen2.5:latest";
const PROFILE_ID = "staffing-recruiter";
const INDEX_NAME = "workers_500k_v1";
const MAX_TURNS = 12;
const MAX_TOOL_ERRORS = 3;
const MAX_DRIFTS = 3;
const TASK_DECK: TaskSpec[] = [
{
id: "R0", operation: "fill: Welder x2 in Toledo, OH",
target_role: "Welder", target_count: 2, target_city: "Toledo", target_state: "OH",
approach_hint: "hybrid_search workers_500k_v1 with sql_filter role+state+city, then sql verify",
},
{
id: "R1", operation: "fill: Welder x2 in Cleveland, OH",
target_role: "Welder", target_count: 2, target_city: "Cleveland", target_state: "OH",
approach_hint: "hybrid_search workers_500k_v1 with sql_filter role+state+city, then sql verify",
},
{
id: "R2", operation: "fill: Welder x3 in Toledo, OH",
target_role: "Welder", target_count: 3, target_city: "Toledo", target_state: "OH",
approach_hint: "hybrid_search workers_500k_v1 with sql_filter role+state+city, then sql verify",
},
];
interface BuildResult {
ok: boolean;
task: TaskSpec;
fills: Fill[];
turns: number;
duration_secs: number;
playbook_id?: string;
entries_after_seed?: number;
error?: string;
}
interface VerifyResult {
profile_activated: boolean;
warmed_indexes: number;
probe_boost_total: number; // sum of playbook_boost across top-K
probe_boosted_hits: number; // how many hits had boost > 0
probe_top_citations: string[]; // playbook_ids cited
geo_discrimination_ok: boolean; // when prior playbook is in different city, boost should NOT bleed
verdict: string; // qwen2.5's natural-language judgment
confidence: number; // 0-100 self-rated
duration_secs: number;
}
interface RoundLedger {
round: number;
task: TaskSpec;
build: BuildResult;
verify: VerifyResult;
score: number; // /10 per round
notes: string[];
}
// ─────────────────────── BUILD phase (two-agent loop) ───────────────────────
async function executeToolCall(name: string, args: Record<string, any>): Promise<any> {
if (name === "hybrid_search") {
const { sql_filter, question, index_name, k } = args;
if (!sql_filter || !question || !index_name) {
throw new Error(`hybrid_search needs sql_filter+question+index_name, got keys=${Object.keys(args).join(",")}`);
}
const r = await fetch(`${GATEWAY}/vectors/hybrid`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sql_filter, question, index_name,
filter_dataset: "workers_500k", id_column: "worker_id",
top_k: k ?? 10, generate: false, use_playbook_memory: true,
}),
});
if (!r.ok) throw new Error(`hybrid → ${r.status}: ${await r.text()}`);
return r.json();
}
if (name === "sql") {
if (!args.query) throw new Error("sql needs query");
if (!/^\s*SELECT/i.test(args.query)) throw new Error("sql allows SELECT only");
return sqlQuery(args.query);
}
return callTool(name, args);
}
function trim(r: any) {
if (r && Array.isArray(r.rows)) return { ...r, rows: r.rows.slice(0, 20) };
if (r && Array.isArray(r.sources)) return { ...r, sources: r.sources.slice(0, 12) };
return r;
}
function fmtTurn(prefix: string, e: Omit<LogEntry, "at">): string {
const c: any = e.content ?? {};
const head = `[${prefix} t${e.turn.toString().padStart(2, "0")} ${e.role.padEnd(8)} ${e.kind.padEnd(14)}]`;
if (e.kind === "tool_call") return `${head} ${c.tool}(${JSON.stringify(c.args ?? {}).slice(0, 70)})`;
if (e.kind === "tool_result") {
if (c.error) return `${head} error: ${c.error}`;
if (Array.isArray(c.sources)) return `${head} hybrid sql=${c.sql_matches} reranked=${c.vector_reranked}`;
if (Array.isArray(c.rows)) return `${head} sql ${c.rows.length} rows`;
return `${head} ${JSON.stringify(c).slice(0, 70)}`;
}
if (e.kind === "critique") return `${head} verdict=${c.verdict} ${(c.notes ?? "").slice(0, 50)}`;
if (e.kind === "propose_done") return `${head} ${(c.fills ?? []).length} fills: ${(c.fills ?? []).map((f: Fill) => f.name).join(", ")}`;
if (e.kind === "consensus_done") return `${head} ✓`;
if (e.kind === "plan") return `${head} ${(c.steps ?? []).length} steps`;
return `${head} ${JSON.stringify(c).slice(0, 60)}`;
}
async function buildPhase(task: TaskSpec, prefix: string): Promise<BuildResult> {
const t0 = Date.now();
const log: LogEntry[] = [];
let turn = 0, sealed: { fills: Fill[]; approach: string } | null = null;
let toolErrors = 0, drifts = 0;
const append = (e: Omit<LogEntry, "at">): LogEntry => {
const full: LogEntry = { ...e, at: new Date().toISOString() };
log.push(full);
console.log(fmtTurn(prefix, e));
return full;
};
try {
while (turn < MAX_TURNS && !sealed) {
turn += 1;
const execRaw = await generate(EXECUTOR_MODEL, executorPrompt(task, log), { temperature: 0.2, max_tokens: 600 });
const execAction = parseAction(execRaw, "executor");
append({ turn, role: "executor", model: EXECUTOR_MODEL, kind: execAction.kind as any, content: execAction });
if (execAction.kind === "tool_call") {
try {
const r = await executeToolCall(execAction.tool, execAction.args);
append({ turn, role: "executor", model: EXECUTOR_MODEL, kind: "tool_result", content: trim(r) });
toolErrors = 0;
} catch (e) {
append({ turn, role: "executor", model: EXECUTOR_MODEL, kind: "tool_result",
content: { error: (e as Error).message, tool: execAction.tool, args: execAction.args } });
toolErrors += 1;
if (toolErrors >= MAX_TOOL_ERRORS) throw new Error(`${MAX_TOOL_ERRORS} consecutive tool errors`);
}
}
const revRaw = await generate(REVIEWER_MODEL, reviewerPrompt(task, log), { temperature: 0.1, max_tokens: 400 });
const revAction = parseAction(revRaw, "reviewer");
append({ turn, role: "reviewer", model: REVIEWER_MODEL, kind: "critique", content: revAction });
if (revAction.kind !== "critique") throw new Error(`reviewer non-critique`);
if (revAction.verdict === "drift") {
drifts += 1;
if (drifts >= MAX_DRIFTS) throw new Error(`${MAX_DRIFTS} consecutive drifts`);
} else drifts = 0;
if (execAction.kind === "propose_done" && revAction.verdict === "approve_done") {
if (execAction.fills.length !== task.target_count) {
throw new Error(`fills=${execAction.fills.length} target=${task.target_count}`);
}
append({ turn, role: "reviewer", model: REVIEWER_MODEL, kind: "consensus_done", content: { fills: execAction.fills } });
sealed = { fills: execAction.fills, approach: (execAction as any).rationale ?? "multi-agent" };
}
}
if (!sealed) throw new Error(`no consensus after ${MAX_TURNS} turns`);
// Phase 19 seed — CANONICAL short text (Pass 1 lesson). The verbose
// executor rationale stays out of the embedding; we keep a separate
// human-readable record in the playbook log.
const canonicalApproach = `${task.target_role.toLowerCase()} fill via hybrid search`;
const canonicalContext = `${task.target_role} fill in ${task.target_city}, ${task.target_state}`;
let playbook_id: string | undefined;
let entries_after_seed: number | undefined;
try {
const sr = await fetch(`${GATEWAY}/vectors/playbook_memory/seed`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
operation: task.operation,
approach: canonicalApproach,
context: canonicalContext,
endorsed_names: sealed.fills.map(f => f.name),
append: true,
}),
});
if (sr.ok) {
const j = await sr.json() as any;
playbook_id = j.playbook_id;
entries_after_seed = j.entries_after;
console.log(`[${prefix}] ↳ seeded id=${playbook_id} entries=${entries_after_seed}`);
} else {
console.warn(`[${prefix}] ↳ seed failed: ${sr.status} ${await sr.text()}`);
}
} catch (e) {
console.warn(`[${prefix}] ↳ seed errored: ${(e as Error).message}`);
}
return {
ok: true, task, fills: sealed.fills, turns: turn,
duration_secs: Math.round((Date.now() - t0) / 1000),
playbook_id, entries_after_seed,
};
} catch (e) {
return {
ok: false, task, fills: [], turns: turn,
duration_secs: Math.round((Date.now() - t0) / 1000),
error: (e as Error).message,
};
}
}
// ─────────────────────── VERIFY phase (fresh single agent) ───────────────────────
async function activateProfile(): Promise<{ ok: boolean; warmed: number; ms: number }> {
const t0 = Date.now();
const r = await fetch(`${GATEWAY}/vectors/profile/${PROFILE_ID}/activate`, { method: "POST" });
const ms = Date.now() - t0;
if (!r.ok) {
console.warn(`profile activation failed: ${r.status} ${await r.text()}`);
return { ok: false, warmed: 0, ms };
}
const j = await r.json() as any;
return { ok: true, warmed: (j.warmed_indexes ?? []).length, ms };
}
async function probeWithBoost(task: TaskSpec) {
const sql_filter = `role = '${task.target_role.replace(/'/g, "''")}' `
+ `AND state = '${task.target_state}' `
+ `AND city = '${task.target_city.replace(/'/g, "''")}'`;
const r = await fetch(`${GATEWAY}/vectors/hybrid`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
index_name: INDEX_NAME, filter_dataset: "workers_500k", id_column: "worker_id",
sql_filter, question: `${task.target_role} in ${task.target_city}, ${task.target_state}`,
top_k: 10, generate: false, use_playbook_memory: true, playbook_memory_k: 15,
}),
});
if (!r.ok) throw new Error(`probe → ${r.status}: ${await r.text()}`);
const j = (await r.json()) as any;
const sources: any[] = j.sources ?? [];
const boostedHits = sources.filter(s => (s.playbook_boost ?? 0) > 0).length;
const totalBoost = sources.reduce((s, x) => s + (x.playbook_boost ?? 0), 0);
const cites = Array.from(new Set(sources.flatMap(s => s.playbook_citations ?? []))).slice(0, 5);
const topNames = sources.slice(0, 5).map(s => {
const t = String(s.chunk_text ?? "");
return t.split("—")[0]?.trim() ?? s.doc_id;
});
return { sources, boostedHits, totalBoost, cites, topNames };
}
// Verifier prompt — fresh agent, no shared log with the build pair. It
// gets the round's task, the prior rounds' sealed playbooks, and the live
// probe result, and renders a human-readable verdict with a confidence.
function verifierPrompt(task: TaskSpec, priorPlaybooks: Array<{op: string; fills: string[]}>,
probe: { boostedHits: number; totalBoost: number; cites: string[]; topNames: string[] }
): string {
const priorBlock = priorPlaybooks.length === 0
? "(no prior playbooks — this is the first round)"
: priorPlaybooks.map((p, i) => ` ${i+1}. ${p.op} → endorsed [${p.fills.join(", ")}]`).join("\n");
return `You are the VERIFIER agent. A fresh round just sealed a playbook on a real staffing
substrate. Your job: judge whether the system learned from prior rounds.
CURRENT ROUND:
task: ${task.operation}
in city: ${task.target_city}, ${task.target_state}
PRIOR PLAYBOOKS (in playbook_memory):
${priorBlock}
I activated the staffing-recruiter profile and ran a hybrid query for this exact task with
use_playbook_memory=true. Live result from the substrate:
- top-5 surfaced workers: ${probe.topNames.join(", ")}
- hits with non-zero playbook_boost: ${probe.boostedHits} / 10
- total boost across top-10: ${probe.totalBoost.toFixed(3)}
- playbook citations: [${probe.cites.join(", ")}]
JUDGE:
1. If a prior playbook covered this same city + role, the boost should fire on the workers
it endorsed (boostedHits > 0, citations non-empty).
2. If no prior playbook covers this combo, boost should be ~0 — that means the system is
correctly NOT bleeding endorsements across geos.
3. Anything in between (e.g. some boost but for the wrong reason) is a partial pass.
Respond with ONE JSON object only:
{"learned": true|false, "verdict": "<one sentence>", "confidence": 0-100}
learned=true means the network behaved as expected for this round (whether that's "boost fired
because it should" or "boost stayed zero because it should"). learned=false means the system
either failed to learn from a relevant prior playbook OR bled an irrelevant one. confidence is
how sure you are.`;
}
async function verifyPhase(task: TaskSpec, ledger: RoundLedger[]): Promise<VerifyResult> {
const t0 = Date.now();
const act = await activateProfile();
const probe = await probeWithBoost(task);
// Decide what counts as geo-correct based on prior playbooks
const priorMatchesThisGeo = ledger.some(r =>
r.build.ok &&
r.task.target_city === task.target_city &&
r.task.target_state === task.target_state &&
r.task.target_role === task.target_role
);
const priorOtherGeo = ledger.some(r =>
r.build.ok &&
r.task.target_role === task.target_role &&
!(r.task.target_city === task.target_city && r.task.target_state === task.target_state)
);
let geo_discrimination_ok: boolean;
if (priorMatchesThisGeo) {
geo_discrimination_ok = probe.boostedHits > 0; // expected lift
} else if (priorOtherGeo) {
geo_discrimination_ok = probe.boostedHits === 0; // must NOT bleed
} else {
geo_discrimination_ok = true; // no signal expected either way
}
// Spin up the fresh verifier agent
const priorPlaybooks = ledger.filter(r => r.build.ok).map(r => ({
op: r.task.operation, fills: r.build.fills.map(f => f.name),
}));
let verdict = "verifier failed to respond"; let confidence = 0;
try {
const raw = await generate(VERIFIER_MODEL, verifierPrompt(task, priorPlaybooks, probe), {
temperature: 0.1, max_tokens: 250,
});
const start = raw.indexOf("{"), end = raw.lastIndexOf("}");
if (start >= 0 && end > start) {
const j = JSON.parse(raw.slice(start, end + 1));
verdict = j.verdict ?? verdict;
confidence = Number(j.confidence) || 0;
}
} catch (e) {
verdict = `verifier parse error: ${(e as Error).message}`;
}
return {
profile_activated: act.ok,
warmed_indexes: act.warmed,
probe_boost_total: probe.totalBoost,
probe_boosted_hits: probe.boostedHits,
probe_top_citations: probe.cites,
geo_discrimination_ok,
verdict, confidence,
duration_secs: Math.round((Date.now() - t0) / 1000),
};
}
// ─────────────────────── round scoring ───────────────────────
function scoreRound(r: RoundLedger): { score: number; notes: string[] } {
const notes: string[] = [];
let s = 0;
if (r.build.ok) { s += 3; notes.push(`✓ build sealed (${r.build.fills.map(f => f.name).join(", ")})`); }
else { notes.push(`✗ build failed: ${r.build.error}`); }
if (r.build.playbook_id) { s += 1; notes.push(`✓ seeded id=${r.build.playbook_id}`); }
if (r.verify.profile_activated) { s += 1; notes.push(`✓ profile activated (warmed=${r.verify.warmed_indexes})`); }
if (r.verify.geo_discrimination_ok) { s += 3; notes.push(`✓ geo discrimination correct (boostedHits=${r.verify.probe_boosted_hits})`); }
else { notes.push(`✗ geo discrimination failed (boostedHits=${r.verify.probe_boosted_hits})`); }
if (r.verify.confidence >= 60) { s += 2; notes.push(`✓ verifier confident (${r.verify.confidence}%): ${r.verify.verdict}`); }
else { notes.push(`◑ verifier confidence ${r.verify.confidence}%: ${r.verify.verdict}`); }
return { score: s, notes };
}
// ─────────────────────── main loop ───────────────────────
async function main() {
console.log(`▶ Network proving — ${TASK_DECK.length} rounds, profile=${PROFILE_ID}`);
console.log(`▶ build pair: ${EXECUTOR_MODEL} + ${REVIEWER_MODEL}; verifier: ${VERIFIER_MODEL}\n`);
const ledger: RoundLedger[] = [];
for (let i = 0; i < TASK_DECK.length; i++) {
const task = TASK_DECK[i];
console.log(`\n══════════ Round ${i} — ${task.operation} ══════════`);
console.log(`\n[${task.id}] BUILD phase (two agents collaborating)`);
const build = await buildPhase(task, task.id);
console.log(`\n[${task.id}] VERIFY phase (fresh agent + hot-swap profile)`);
const verify = await verifyPhase(task, ledger);
console.log(` profile=${verify.profile_activated ? "ok" : "fail"} warmed=${verify.warmed_indexes} `
+ `boosted=${verify.probe_boosted_hits}/10 totalBoost=${verify.probe_boost_total.toFixed(3)} `
+ `cites=${verify.probe_top_citations.length} confidence=${verify.confidence}%`);
console.log(` verdict: ${verify.verdict}`);
const round: RoundLedger = { round: i, task, build, verify, score: 0, notes: [] };
const sc = scoreRound(round);
round.score = sc.score; round.notes = sc.notes;
ledger.push(round);
console.log(`\n Round ${i} score: ${round.score}/10`);
for (const n of round.notes) console.log(` ${n}`);
}
console.log(`\n══════════ Network viability summary ══════════`);
const total = ledger.reduce((s, r) => s + r.score, 0);
const max = ledger.length * 10;
const avg = total / ledger.length;
for (const r of ledger) console.log(` R${r.round} ${r.task.target_city.padEnd(10)} ${r.task.target_role.padEnd(20)} ${r.score}/10`);
console.log(`\n TOTAL: ${total}/${max} AVG: ${avg.toFixed(1)}/10`);
// Hard gate: at least 2/3 rounds must show the verifier is confident enough
// AND build phase succeeded
const passed = ledger.filter(r => r.build.ok && r.score >= 6).length;
if (passed < Math.ceil(ledger.length * 2 / 3)) {
throw new Error(`network proving gate failed — only ${passed}/${ledger.length} rounds passed (need ≥${Math.ceil(ledger.length * 2 / 3)})`);
}
console.log(`\n✓ Network proven over ${passed}/${ledger.length} rounds`);
process.exit(0);
}
main().catch(e => {
console.error(`\n✗ ${(e as Error).message}`);
if ((e as any).stack) console.error((e as any).stack);
process.exit(1);
});
+302
View File
@@ -0,0 +1,302 @@
// Two-agent orchestrator. Both agents run as concurrent async loops
// coordinated through a shared in-memory log; one turn of executor then
// one turn of reviewer, interleaved until consensus_done, drift-cycle
// blown, or hard turn cap. On success writes a playbook JSON; on failure
// exits non-zero with the full log for inspection.
//
// Fail-fast: every caught error is appended to the log AND rethrown, so
// the orchestrator top-level catches, dumps, and exits with code 1. The
// test harness reads the exit code to decide if the substrate is healthy.
import {
type LogEntry,
type TaskSpec,
type Action,
type Fill,
callTool,
hybridSearch,
sqlQuery,
generate,
parseAction,
executorPrompt,
reviewerPrompt,
GATEWAY,
} from "./agent.ts";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
const EXECUTOR_MODEL = "mistral:latest";
const REVIEWER_MODEL = "qwen2.5:latest";
const MAX_TURNS = 12; // executor turns; reviewer gets one per
const MAX_CONSECUTIVE_DRIFTS = 3; // drift-cycle blown → give up
// Default task. Override via argv[2] if you want something else; see
// `parseTaskFromArg`. Picked from the real-world staffing pattern but
// not in the existing successful_playbooks list — this is a fresh fill.
// Default task lifted from the production pattern in successful_playbooks.
// Toledo, OH has 342 welders in workers_500k so supply is ample — the test
// is about collaboration and drift correction, not needle-in-haystack.
const DEFAULT_TASK: TaskSpec = {
id: `task-${Date.now()}`,
operation: "fill: Welder x2 in Toledo, OH",
target_role: "Welder",
target_count: 2,
target_city: "Toledo",
target_state: "OH",
approach_hint: "hybrid search against workers_500k_v1, narrow by role+city+state+availability, rank semantically",
};
function parseTaskFromArg(): TaskSpec {
const arg = process.argv[2];
if (!arg) return DEFAULT_TASK;
// Accept "role:Welder count:2 city:Columbus state:OH" style for ad-hoc
// tasks without standing up a JSON file. Anything more complex, feed
// it a JSON path.
if (arg.endsWith(".json")) {
return JSON.parse(require("node:fs").readFileSync(arg, "utf-8"));
}
const kv: Record<string, string> = {};
for (const token of arg.split(/\s+/)) {
const [k, ...v] = token.split(":");
kv[k] = v.join(":");
}
return {
id: `task-${Date.now()}`,
operation: `fill: ${kv.role} x${kv.count} in ${kv.city}, ${kv.state}`,
target_role: kv.role,
target_count: Number(kv.count),
target_city: kv.city,
target_state: kv.state,
approach_hint: kv.hint ?? "hybrid search",
};
}
// Helper: pretty one-line print for each log entry so the human watching
// stdout can follow without pulling the JSONL file.
// Defensive one-line formatter. Models sometimes omit optional fields
// (rationale, notes), so every access is guarded.
function fmt(e: LogEntry): string {
const tag = `[t${e.turn.toString().padStart(2, "0")} ${e.role.padEnd(8)} ${e.kind.padEnd(14)}]`;
const c = e.content ?? {};
const trim = (s: any, n: number) => String(s ?? "").slice(0, n);
if (e.kind === "tool_call")
return `${tag} ${c.tool}(${JSON.stringify(c.args ?? {}).slice(0, 80)}) — ${trim(c.rationale, 60)}`;
if (e.kind === "tool_result") {
const rows = c?.rows?.length ?? c?.sources?.length ?? undefined;
return `${tag} ${rows !== undefined ? `rows=${rows}` : JSON.stringify(c).slice(0, 80)}`;
}
if (e.kind === "critique") return `${tag} verdict=${c.verdict} — ${trim(c.notes, 80)}`;
if (e.kind === "propose_done")
return `${tag} ${c.fills?.length ?? 0} fills: ${(c.fills ?? []).map((f: Fill) => f.name).join(", ")}`;
if (e.kind === "consensus_done") return `${tag} ✓`;
if (e.kind === "plan") return `${tag} ${c.steps?.length ?? 0} steps: ${(c.steps ?? []).slice(0, 2).join(" / ")}`;
if (e.kind === "error") return `${tag} ${c.message ?? c}`;
return `${tag} ${JSON.stringify(c).slice(0, 100)}`;
}
// Execute one tool call. The tool catalog in the prompt lists both the
// registered Phase 12 tools AND a pseudo-tool "hybrid_search" for the
// /vectors/hybrid endpoint — unify here so the executor doesn't need to
// know which surface a capability lives on.
async function executeToolCall(name: string, args: Record<string, any>): Promise<any> {
if (name === "hybrid_search") {
const { sql_filter, question, index_name, k } = args;
if (!sql_filter || !question || !index_name) {
throw new Error(`hybrid_search needs sql_filter + question + index_name, got ${JSON.stringify(args)}`);
}
// Pass through to /vectors/hybrid. id_column defaults to worker_id
// server-side, which is what workers_500k uses.
const body: any = { sql_filter, question, index_name, top_k: k ?? 10, generate: false };
return (await (await fetch("http://localhost:3100/vectors/hybrid", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})).json());
}
if (name === "sql") {
const { query } = args;
if (!query || typeof query !== "string") throw new Error(`sql needs query (string), got ${JSON.stringify(args)}`);
if (!/^\s*SELECT/i.test(query)) throw new Error(`sql tool allows SELECT only: ${query}`);
return sqlQuery(query);
}
// Fall through to Phase 12 registry for any other named tool.
return callTool(name, args);
}
async function main() {
const task = parseTaskFromArg();
const log: LogEntry[] = [];
let turn = 0;
let consecutiveDrifts = 0;
let sealed: { fills: Fill[]; approach: string } | null = null;
const append = (e: Omit<LogEntry, "at">): LogEntry => {
const full: LogEntry = { ...e, at: new Date().toISOString() };
log.push(full);
console.log(fmt(full));
return full;
};
console.log(`▶ task: ${task.operation}`);
console.log(`▶ executor=${EXECUTOR_MODEL} reviewer=${REVIEWER_MODEL}`);
console.log();
try {
while (turn < MAX_TURNS && !sealed) {
turn += 1;
// --- EXECUTOR TURN ---
const execRaw = await generate(EXECUTOR_MODEL, executorPrompt(task, log), {
temperature: 0.2,
max_tokens: 600,
});
let execAction: Action;
try {
execAction = parseAction(execRaw, "executor");
} catch (e) {
append({ turn, role: "executor", model: EXECUTOR_MODEL, kind: "error",
content: { message: (e as Error).message, raw: execRaw.slice(0, 400) } });
throw e;
}
append({ turn, role: "executor", model: EXECUTOR_MODEL,
kind: execAction.kind as any, content: execAction });
// If tool_call, execute and feed result back into the log. Tool
// validation / server errors come back as a tool_result with an
// `error` field — the executor reads its own error on the next turn
// and self-corrects (e.g. "oh, I forgot the `question` argument").
// This is softer than hard-failing the orchestrator: the whole
// point of two-agent collaboration is letting agents learn from
// immediate feedback instead of crashing the run.
if (execAction.kind === "tool_call") {
try {
const result = await executeToolCall(execAction.tool, execAction.args);
const trimmed = trimResult(result);
append({ turn, role: "executor", model: EXECUTOR_MODEL,
kind: "tool_result", content: trimmed });
} catch (e) {
append({ turn, role: "executor", model: EXECUTOR_MODEL,
kind: "tool_result",
content: { error: (e as Error).message, tool: execAction.tool, args: execAction.args } });
// Count as a soft drift — if the executor keeps throwing tool
// errors, consecutiveDrifts still trips the abort.
consecutiveDrifts += 1;
if (consecutiveDrifts >= MAX_CONSECUTIVE_DRIFTS) {
throw new Error(`aborting — ${MAX_CONSECUTIVE_DRIFTS} consecutive tool errors, executor can't self-correct`);
}
}
}
// --- REVIEWER TURN ---
const revRaw = await generate(REVIEWER_MODEL, reviewerPrompt(task, log), {
temperature: 0.1,
max_tokens: 400,
});
let revAction: Action;
try {
revAction = parseAction(revRaw, "reviewer");
} catch (e) {
append({ turn, role: "reviewer", model: REVIEWER_MODEL, kind: "error",
content: { message: (e as Error).message, raw: revRaw.slice(0, 400) } });
throw e;
}
append({ turn, role: "reviewer", model: REVIEWER_MODEL,
kind: "critique", content: revAction });
if (revAction.kind !== "critique") throw new Error(`reviewer emitted non-critique: ${revAction.kind}`);
if (revAction.verdict === "drift") {
consecutiveDrifts += 1;
if (consecutiveDrifts >= MAX_CONSECUTIVE_DRIFTS) {
throw new Error(`aborting — ${MAX_CONSECUTIVE_DRIFTS} consecutive drift flags, executor can't self-correct`);
}
} else {
consecutiveDrifts = 0;
}
// Consensus: executor proposed done AND reviewer approved.
if (execAction.kind === "propose_done" && revAction.verdict === "approve_done") {
if (execAction.fills.length !== task.target_count) {
throw new Error(`consensus malformed — ${execAction.fills.length} fills vs target ${task.target_count}`);
}
append({ turn, role: "reviewer", model: REVIEWER_MODEL, kind: "consensus_done",
content: { fills: execAction.fills } });
sealed = { fills: execAction.fills, approach: execAction.rationale };
}
}
if (!sealed) throw new Error(`no consensus after ${MAX_TURNS} turns — task incomplete`);
// Write playbook entry matching the successful_playbooks schema.
const playbook = {
timestamp: new Date().toISOString(),
operation: task.operation,
approach: sealed.approach,
result: `${sealed.fills.length}/${task.target_count} filled → ${sealed.fills.map(f => f.name).join(", ")}`,
context: `executor=${EXECUTOR_MODEL} reviewer=${REVIEWER_MODEL} turns=${turn}`,
task,
fills: sealed.fills,
log,
};
await mkdir("./tests/multi-agent/playbooks", { recursive: true });
const path = join("./tests/multi-agent/playbooks", `${task.id}.json`);
await writeFile(path, JSON.stringify(playbook, null, 2));
console.log(`\n✓ playbook written: ${path}`);
console.log(` ${playbook.result}`);
// Phase 19.5: write-through to playbook_memory. The sealed fills are
// the endorsement; next semantically-similar query will surface them
// higher. /seed bypasses the successful_playbooks ingest round-trip
// — when that ingest path ships, this block should switch to append
// + rebuild instead.
try {
// Seed context is what the embedding model actually sees alongside
// the operation — so it has to carry task-semantic content (role,
// city, scenario) rather than orchestrator bookkeeping. We stash
// the bookkeeping in the full playbook JSON instead (see playbook
// object above) where operators can grep it without it polluting
// the ranking signal.
const seedContext = task.approach_hint
?? `${task.target_role} fill in ${task.target_city}, ${task.target_state}`;
const seedRes = await fetch(`${GATEWAY}/vectors/playbook_memory/seed`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
operation: task.operation,
approach: sealed.approach || "multi-agent → hybrid search",
context: seedContext,
endorsed_names: sealed.fills.map(f => f.name),
append: true,
}),
});
if (seedRes.ok) {
const j = await seedRes.json() as any;
console.log(` ↳ playbook_memory seeded: id=${j.playbook_id} entries=${j.entries_after}`);
} else {
console.warn(` ↳ playbook_memory seed failed: ${seedRes.status} ${await seedRes.text()}`);
}
} catch (e) {
console.warn(` ↳ playbook_memory seed errored: ${(e as Error).message}`);
}
process.exit(0);
} catch (e) {
console.error(`\n✗ ${(e as Error).message}`);
// Still persist the log for inspection.
await mkdir("./tests/multi-agent/playbooks", { recursive: true });
const path = join("./tests/multi-agent/playbooks", `${task.id}-FAILED.json`);
await writeFile(path, JSON.stringify({ task, error: (e as Error).message, log }, null, 2));
console.error(` log dumped: ${path}`);
process.exit(1);
}
}
function trimResult(r: any): any {
if (r && Array.isArray(r.rows)) {
return { ...r, rows: r.rows.slice(0, 20), _trimmed: r.rows.length > 20 ? `${r.rows.length - 20} more rows` : undefined };
}
return r;
}
main();
+400
View File
@@ -0,0 +1,400 @@
// Two-agent x two-tasks parallel real-world test with per-playbook rating.
//
// Spawns two independent (executor, reviewer) pairs concurrently, each
// driving a different staffing fill against the live substrate. After
// each pair seals a playbook, verifies the fill against workers_500k,
// confirms the seed reached playbook_memory, and re-runs the same query
// with use_playbook_memory=true to prove the boost fires.
//
// Errors fail fast — any HTTP error, parse error, or rating failure is
// rethrown so bun exits non-zero. Run with:
//
// bun run tests/multi-agent/run_e2e_rated.ts
//
// VRAM note: both pairs call the same two Ollama models (mistral +
// qwen2.5). Ollama queues at the model level, so "parallel" is concurrent
// orchestration, not concurrent inference — the loops interleave on the
// shared models. That's intentional: it stresses the same realistic
// path two staffing coordinators would hit if they both opened the app
// at 8am.
import {
type LogEntry,
type TaskSpec,
type Action,
type Fill,
GATEWAY,
generate,
parseAction,
executorPrompt,
reviewerPrompt,
sqlQuery,
callTool,
} from "./agent.ts";
const EXECUTOR_MODEL = "mistral:latest";
const REVIEWER_MODEL = "qwen2.5:latest";
const MAX_TURNS = 12;
const MAX_CONSECUTIVE_DRIFTS = 3;
const INDEX_NAME = "workers_500k_v1";
interface RunResult {
task: TaskSpec;
ok: boolean;
turns: number;
duration_secs: number;
fills: Fill[];
log: LogEntry[];
approach: string;
error?: string;
}
// ────────────────────────── orchestrator (function form) ──────────────────────────
async function runOrchestrator(task: TaskSpec, prefix: string): Promise<RunResult> {
const start = Date.now();
const log: LogEntry[] = [];
let turn = 0;
let consecutiveDrifts = 0;
// Track tool errors separately from drift verdicts. Reviewer saying
// "continue" or "approve_done" should NOT reset a streak of malformed
// tool calls — that's a different failure mode (model can't form the
// call) than "executor is on the wrong path" (model is off-topic).
let consecutiveToolErrors = 0;
let sealed: { fills: Fill[]; approach: string } | null = null;
const append = (e: Omit<LogEntry, "at">): LogEntry => {
const full: LogEntry = { ...e, at: new Date().toISOString() };
log.push(full);
console.log(`[${prefix}] [t${e.turn.toString().padStart(2, "0")} ${e.role.padEnd(8)} ${e.kind.padEnd(14)}] ${shortContent(e)}`);
return full;
};
try {
while (turn < MAX_TURNS && !sealed) {
turn += 1;
// Executor
const execRaw = await generate(EXECUTOR_MODEL, executorPrompt(task, log), { temperature: 0.2, max_tokens: 600 });
const execAction = parseAction(execRaw, "executor");
append({ turn, role: "executor", model: EXECUTOR_MODEL, kind: execAction.kind as any, content: execAction });
if (execAction.kind === "tool_call") {
try {
const result = await executeToolCall(execAction.tool, execAction.args);
append({ turn, role: "executor", model: EXECUTOR_MODEL, kind: "tool_result", content: trimResult(result) });
consecutiveToolErrors = 0;
} catch (e) {
append({ turn, role: "executor", model: EXECUTOR_MODEL, kind: "tool_result",
content: { error: (e as Error).message, tool: execAction.tool, args: execAction.args } });
consecutiveToolErrors += 1;
if (consecutiveToolErrors >= MAX_CONSECUTIVE_DRIFTS) {
throw new Error(`${MAX_CONSECUTIVE_DRIFTS} consecutive tool errors — executor can't form a valid call`);
}
}
}
// Reviewer
const revRaw = await generate(REVIEWER_MODEL, reviewerPrompt(task, log), { temperature: 0.1, max_tokens: 400 });
const revAction = parseAction(revRaw, "reviewer");
append({ turn, role: "reviewer", model: REVIEWER_MODEL, kind: "critique", content: revAction });
if (revAction.kind !== "critique") throw new Error(`reviewer non-critique: ${revAction.kind}`);
if (revAction.verdict === "drift") {
consecutiveDrifts += 1;
if (consecutiveDrifts >= MAX_CONSECUTIVE_DRIFTS) throw new Error(`${MAX_CONSECUTIVE_DRIFTS} consecutive drifts`);
} else consecutiveDrifts = 0;
if (execAction.kind === "propose_done" && revAction.verdict === "approve_done") {
if (execAction.fills.length !== task.target_count) {
throw new Error(`fills=${execAction.fills.length} target=${task.target_count}`);
}
append({ turn, role: "reviewer", model: REVIEWER_MODEL, kind: "consensus_done", content: { fills: execAction.fills } });
sealed = { fills: execAction.fills, approach: (execAction as any).rationale ?? "multi-agent → hybrid" };
}
}
if (!sealed) throw new Error(`no consensus after ${MAX_TURNS} turns`);
// Phase 19 write-through: seed playbook_memory so the next semantically
// similar query benefits from this fill. Mirrors orchestrator.ts. Names
// are the consensus fills' display names — that's what the boost-key
// matcher (city, state, name) will look up against worker chunks.
try {
const seedRes = await fetch(`${GATEWAY}/vectors/playbook_memory/seed`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
operation: task.operation,
approach: sealed.approach || "multi-agent → hybrid search",
context: task.approach_hint ?? `${task.target_role} fill in ${task.target_city}, ${task.target_state}`,
endorsed_names: sealed.fills.map(f => f.name),
append: true,
}),
});
if (!seedRes.ok) {
console.warn(`[${prefix}] seed warning: ${seedRes.status} ${await seedRes.text()}`);
} else {
const j = await seedRes.json() as any;
console.log(`[${prefix}] ↳ seeded playbook_memory: id=${j.playbook_id} entries=${j.entries_after}`);
}
} catch (e) {
console.warn(`[${prefix}] seed errored: ${(e as Error).message}`);
}
return {
task, ok: true, turns: turn, fills: sealed.fills, approach: sealed.approach,
duration_secs: Math.round((Date.now() - start) / 1000), log,
};
} catch (e) {
return {
task, ok: false, turns: turn, fills: [], approach: "",
duration_secs: Math.round((Date.now() - start) / 1000), log,
error: (e as Error).message,
};
}
}
async function executeToolCall(name: string, args: Record<string, any>): Promise<any> {
if (name === "hybrid_search") {
const { sql_filter, question, index_name, k } = args;
if (!sql_filter || !question || !index_name) throw new Error(`hybrid_search needs sql_filter+question+index_name`);
const r = await fetch(`${GATEWAY}/vectors/hybrid`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sql_filter, question, index_name, top_k: k ?? 10, generate: false, use_playbook_memory: true }),
});
if (!r.ok) throw new Error(`hybrid_search → ${r.status}: ${await r.text()}`);
return r.json();
}
if (name === "sql") {
if (!args.query || typeof args.query !== "string") throw new Error("sql needs query");
if (!/^\s*SELECT/i.test(args.query)) throw new Error("sql allows SELECT only");
return sqlQuery(args.query);
}
return callTool(name, args);
}
function trimResult(r: any): any {
if (r && Array.isArray(r.rows)) return { ...r, rows: r.rows.slice(0, 20) };
if (r && Array.isArray(r.sources)) return { ...r, sources: r.sources.slice(0, 12) };
return r;
}
function shortContent(e: Omit<LogEntry, "at">): string {
const c: any = e.content ?? {};
if (e.kind === "tool_call") return `${c.tool}(${JSON.stringify(c.args ?? {}).slice(0, 70)})`;
if (e.kind === "tool_result") {
if (c.error) return `error: ${c.error}`;
if (Array.isArray(c.sources)) return `hybrid sql=${c.sql_matches} reranked=${c.vector_reranked}`;
if (Array.isArray(c.rows)) return `sql ${c.rows.length} rows`;
return JSON.stringify(c).slice(0, 80);
}
if (e.kind === "critique") return `verdict=${c.verdict} ${(c.notes ?? "").slice(0, 60)}`;
if (e.kind === "propose_done") return `${(c.fills ?? []).length} fills: ${(c.fills ?? []).map((f: Fill) => f.name).join(", ")}`;
if (e.kind === "consensus_done") return "✓";
if (e.kind === "plan") return `${(c.steps ?? []).length} steps`;
return JSON.stringify(c).slice(0, 80);
}
// ────────────────────────── playbook rating ──────────────────────────
interface Rating {
geo: number; // 0-2: fills actually in target city/state
authenticity: number; // 0-2: fills' worker_ids exist in workers_500k
persistence: number; // 0-2: playbook_memory entry count grew correctly
boost_firing: number; // 0-3: follow-up query shows non-zero boost
speed: number; // 0-1: completed under 4 min
total: number; // /10
notes: string[];
}
interface MemoryStats { entries: number; total_names_endorsed: number }
async function fetchMemoryStats(): Promise<MemoryStats> {
const r = await fetch(`${GATEWAY}/vectors/playbook_memory/stats`);
if (!r.ok) throw new Error(`stats → ${r.status}`);
return r.json() as Promise<MemoryStats>;
}
// Try to resolve a fill's candidate_id to a workers_500k row. Accepts
// "W500K-7995" (vector doc_id with prefix) and "7995" (raw worker_id).
async function lookupWorker(candidate_id: string): Promise<{ worker_id: number; name: string; city: string; state: string; role: string } | null> {
const numStr = candidate_id.replace(/^W500K-/i, "").replace(/[^\d]/g, "");
if (!numStr) return null;
const num = parseInt(numStr, 10);
if (!Number.isFinite(num)) return null;
const r = await sqlQuery(`SELECT worker_id, name, city, state, role FROM workers_500k WHERE worker_id = ${num} LIMIT 1`);
return (r.rows && r.rows[0]) ?? null;
}
// Re-run a hybrid query that mirrors the contract — proves the freshly
// seeded playbook actually lifts a future search.
async function verifyBoostFires(task: TaskSpec): Promise<{ boostedHits: number; sampleCitations: string[]; topBoost: number }> {
// Mirror the contract's actual geo. The playbook stored (city, state)
// from the operation; if the verify SQL doesn't restrict to the same
// city, the candidate pool may not include the seeded workers and the
// boost has nothing to lift. The contract pattern in production also
// includes city — recruiters fill specific cities, not whole states.
const sql_filter = `role = '${task.target_role.replace(/'/g, "''")}' `
+ `AND state = '${task.target_state}' `
+ `AND city = '${task.target_city.replace(/'/g, "''")}'`;
const r = await fetch(`${GATEWAY}/vectors/hybrid`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
index_name: INDEX_NAME, filter_dataset: "workers_500k", id_column: "worker_id",
sql_filter, question: `${task.target_role} in ${task.target_city}, ${task.target_state}`,
top_k: 10, generate: false, use_playbook_memory: true, playbook_memory_k: 15,
}),
});
if (!r.ok) throw new Error(`verify hybrid → ${r.status}: ${await r.text()}`);
const j = (await r.json()) as any;
const sources: any[] = j.sources ?? [];
const boosted = sources.filter(s => (s.playbook_boost ?? 0) > 0);
const cites = boosted.flatMap(s => s.playbook_citations ?? []).slice(0, 5);
const top = sources.reduce((m, s) => Math.max(m, s.playbook_boost ?? 0), 0);
return { boostedHits: boosted.length, sampleCitations: cites, topBoost: top };
}
async function ratePlaybook(
result: RunResult,
statsBefore: MemoryStats,
statsAfter: MemoryStats,
): Promise<Rating> {
const notes: string[] = [];
let geo = 0, authenticity = 0, persistence = 0, boost_firing = 0, speed = 0;
// 1. Geo + authenticity per fill
for (const f of result.fills) {
const w = await lookupWorker(f.candidate_id).catch(() => null);
if (!w) { notes.push(`✗ candidate_id ${f.candidate_id} not in workers_500k`); continue; }
authenticity += 1;
if (w.city.toLowerCase() === result.task.target_city.toLowerCase()
&& w.state === result.task.target_state) {
geo += 1;
} else {
notes.push(`◑ ${w.name} (id=${w.worker_id}) is in ${w.city}, ${w.state}, not ${result.task.target_city}, ${result.task.target_state}`);
}
}
geo = Math.min(geo, 2);
authenticity = Math.min(authenticity, 2);
// 2. Persistence
const grew = statsAfter.entries - statsBefore.entries;
if (grew === 1) { persistence = 2; notes.push(`✓ playbook_memory grew by exactly 1`); }
else if (grew >= 1) { persistence = 1; notes.push(`◑ playbook_memory grew by ${grew} (expected 1)`); }
else { notes.push(`✗ playbook_memory did not grow (before=${statsBefore.entries} after=${statsAfter.entries})`); }
// 3. Boost firing — re-run the same query and see if it lifts anything
const v = await verifyBoostFires(result.task).catch(e => { notes.push(`✗ verify hybrid failed: ${(e as Error).message}`); return null; });
if (v) {
if (v.boostedHits >= 2) boost_firing = 3;
else if (v.boostedHits === 1) boost_firing = 2;
else if (v.topBoost > 0) boost_firing = 1;
else boost_firing = 0;
notes.push(`boost re-query: ${v.boostedHits}/10 hits boosted, top=+${v.topBoost.toFixed(3)}, citations=${v.sampleCitations.slice(0, 3).join(",")}`);
}
// 4. Speed
if (result.duration_secs <= 240) speed = 1;
else notes.push(`◑ slow: ${result.duration_secs}s (>240)`);
const total = geo + authenticity + persistence + boost_firing + speed;
return { geo, authenticity, persistence, boost_firing, speed, total, notes };
}
function fmtRating(r: Rating): string {
return `geo=${r.geo}/2 auth=${r.authenticity}/2 persist=${r.persistence}/2 boost=${r.boost_firing}/3 speed=${r.speed}/1 → ${r.total}/10`;
}
// ────────────────────────── main ──────────────────────────
async function main() {
const taskA: TaskSpec = {
id: `e2e-A-${Date.now()}`,
operation: "fill: Welder x2 in Toledo, OH",
target_role: "Welder", target_count: 2, target_city: "Toledo", target_state: "OH",
approach_hint: "hybrid_search against workers_500k_v1 with sql_filter on role+city+state, then sql verify",
};
const taskB: TaskSpec = {
id: `e2e-B-${Date.now()}`,
operation: "fill: Forklift Operator x2 in Nashville, TN",
target_role: "Forklift Operator", target_count: 2, target_city: "Nashville", target_state: "TN",
approach_hint: "hybrid_search against workers_500k_v1 with sql_filter on role+city+state, then sql verify",
};
console.log(`▶ parallel real-world test`);
console.log(` A: ${taskA.operation}`);
console.log(` B: ${taskB.operation}`);
console.log(` models: executor=${EXECUTOR_MODEL} reviewer=${REVIEWER_MODEL}\n`);
const statsBefore = await fetchMemoryStats();
console.log(`▶ playbook_memory before: ${statsBefore.entries} entries, ${statsBefore.total_names_endorsed} endorsed names\n`);
// Run both pairs in parallel. Each is its own (executor, reviewer)
// conversation; they do NOT see each other's logs.
const [resA, resB] = await Promise.all([
runOrchestrator(taskA, "A"),
runOrchestrator(taskB, "B"),
]);
console.log(`\n▶ both orchestrators returned`);
console.log(` A: ok=${resA.ok} turns=${resA.turns} ${resA.duration_secs}s ${resA.error ?? ""}`);
console.log(` B: ok=${resB.ok} turns=${resB.turns} ${resB.duration_secs}s ${resB.error ?? ""}`);
if (!resA.ok && !resB.ok) {
throw new Error(`both orchestrators failed — substrate or models in bad state`);
}
const statsMid = await fetchMemoryStats();
console.log(`\n▶ playbook_memory after both runs: ${statsMid.entries} entries (+${statsMid.entries - statsBefore.entries})\n`);
// Rate each successful playbook. We compute persistence per task by
// splitting the growth — both seeded sequentially-ish, so each should
// contribute 1.
const ratings: Array<{ id: string; ok: boolean; rating?: Rating; error?: string }> = [];
if (resA.ok) {
const beforeForA: MemoryStats = { entries: statsBefore.entries, total_names_endorsed: statsBefore.total_names_endorsed };
const afterForA: MemoryStats = { entries: statsBefore.entries + (resA.fills.length > 0 ? 1 : 0), total_names_endorsed: statsBefore.total_names_endorsed };
// Use real measured numbers when they're unambiguous (only one task succeeded)
const ra = await ratePlaybook(resA, beforeForA, resB.ok ? afterForA : statsMid);
ratings.push({ id: "A", ok: true, rating: ra });
} else ratings.push({ id: "A", ok: false, error: resA.error });
if (resB.ok) {
const beforeForB: MemoryStats = resA.ok
? { entries: statsBefore.entries + 1, total_names_endorsed: statsBefore.total_names_endorsed }
: statsBefore;
const rb = await ratePlaybook(resB, beforeForB, statsMid);
ratings.push({ id: "B", ok: true, rating: rb });
} else ratings.push({ id: "B", ok: false, error: resB.error });
console.log(`\n▶ Per-playbook ratings:\n`);
for (const r of ratings) {
if (!r.ok) {
console.log(` ${r.id}: FAILED — ${r.error}`);
continue;
}
console.log(` ${r.id}: ${fmtRating(r.rating!)}`);
for (const n of r.rating!.notes) console.log(` ${n}`);
}
const totals = ratings.filter(r => r.rating).map(r => r.rating!.total);
if (totals.length === 0) {
throw new Error(`no playbooks rated — both orchestrators failed`);
}
const min = Math.min(...totals);
const avg = totals.reduce((s, t) => s + t, 0) / totals.length;
console.log(`\n▶ Summary: avg=${avg.toFixed(1)}/10 min=${min}/10`);
// Hard gate: any rating below 5 means the loop is broken end-to-end.
if (min < 5) throw new Error(`rating gate failed — min ${min}/10 (need ≥5)`);
console.log(`\n✓ end-to-end real-world test passed`);
process.exit(0);
}
main().catch(e => {
console.error(`\n✗ ${(e as Error).message}`);
if ((e as any).stack) console.error((e as any).stack);
process.exit(1);
});
+822
View File
@@ -0,0 +1,822 @@
// A day in the life — the real-world scenario test.
//
// Runs six events against the live substrate: baseline_fill, recurring,
// expansion, emergency, misplacement, retrospective. Each event
// exercises a different pressure pattern; each one produces actionable
// artifacts (SMS drafts, client emails, dispatch log) alongside the
// ranking output; the run as a whole is self-audited at EOD against six
// gap categories (supply, embedding, fairness, drift, tool, write-through).
//
// Design notes:
// - Compressed clock. The "08:00" in an event spec is a label for the
// output, not a wall-clock gate. The full scenario runs in minutes.
// - One script, shared state. Each event mutates the same roster +
// gap_signals + artifacts in-memory, then persists at EOD.
// - Fail-soft per event. A drift-abort or tool error on one event
// records a gap_signal and moves on; we explicitly want to see which
// events the substrate can't handle, not abort the whole run.
// - Every fill event routes through the same executor/reviewer loop as
// the single-task orchestrator — just driven in sequence rather than
// standalone, with event-specific extra constraints in the prompt.
import {
type LogEntry,
type TaskSpec,
type Action,
type Fill,
callTool,
hybridSearch,
sqlQuery,
generate,
parseAction,
executorPrompt,
reviewerPrompt,
GATEWAY,
} from "./agent.ts";
import { mkdir, writeFile, appendFile } from "node:fs/promises";
import { join } from "node:path";
const EXECUTOR_MODEL = "mistral:latest";
const REVIEWER_MODEL = "qwen2.5:latest";
const DRAFT_MODEL = "qwen2.5:latest"; // artifact generation; short outputs
const MAX_TURNS = 14;
const MAX_CONSECUTIVE_DRIFTS = 3;
const WORKERS_INDEX = "workers_500k_v1";
const WORKERS_DATASET = "workers_500k";
// =================== Event + scenario types ===================
type EventKind = "baseline_fill" | "recurring" | "expansion" | "emergency" | "misplacement";
interface FillEvent {
kind: EventKind;
at: string; // display label like "08:00"
role: string;
count: number;
city: string;
state: string;
shift_start?: string; // "08:00 AM" for SMS/email drafts
scenario_note?: string; // extra context the agents should know
deadline?: string; // emergency events carry this, shown to reviewer
exclude_worker_ids?: string[]; // misplacement: the lost worker
replaces_event?: string; // misplacement back-ref for reporting
}
interface ScenarioSpec {
client: string;
date: string;
events: FillEvent[];
}
interface EventResult {
event: FillEvent;
ok: boolean;
fills: Fill[];
turns: number;
duration_secs: number;
error?: string;
gap_signals: string[]; // pulled into the cross-event gap report
sources_first_score?: number;
sources_last_score?: number;
pool_size?: number; // sql_matches from the first hybrid_search
playbook_citations?: string[];
}
interface RosterEntry {
worker_id: string;
name: string;
booked_for: string; // event at-label
role: string;
city: string;
state: string;
status: "confirmed" | "no_show" | "rebooked_elsewhere";
}
interface ScenarioContext {
spec: ScenarioSpec;
out_dir: string;
roster: RosterEntry[];
results: EventResult[];
gap_signals: Array<{ event: string; category: string; detail: string }>;
}
// =================== Default scenario ===================
const DEFAULT_SCENARIO: ScenarioSpec = {
client: "Riverfront Steel",
date: "2026-04-21",
events: [
{
kind: "baseline_fill",
at: "08:00",
role: "Warehouse Associate",
count: 3,
city: "Toledo",
state: "OH",
shift_start: "08:00 AM",
scenario_note: "Regular Monday morning shift, 8-hour.",
},
{
kind: "recurring",
at: "10:30",
role: "Machine Operator",
count: 2,
city: "Toledo",
state: "OH",
shift_start: "11:00 AM",
scenario_note: "Recurring Tuesday/Thursday slot — prior workers may still be available.",
},
{
kind: "expansion",
at: "12:15",
role: "Forklift Operator",
count: 5,
city: "Toledo",
state: "OH",
shift_start: "01:00 PM",
scenario_note: "New warehouse location opening, five-worker team needed.",
},
{
kind: "emergency",
at: "14:00",
role: "Loader",
count: 4,
city: "Toledo",
state: "OH",
shift_start: "04:00 PM same day",
deadline: "16:00",
scenario_note: "Walkoff incident — replacement crew needed by 16:00 sharp.",
},
{
kind: "misplacement",
at: "15:45",
role: "Warehouse Associate",
count: 1,
city: "Toledo",
state: "OH",
shift_start: "remainder of 08:00 shift",
scenario_note: "One worker from the 08:00 fill didn't show; rebuild the gap.",
replaces_event: "08:00",
},
],
};
// =================== Low-level helpers shared across events ===================
async function httpJson<T>(url: string, body?: any): Promise<T> {
const res = await fetch(url, {
method: body ? "POST" : "GET",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return (await res.json()) as T;
}
function fmt(e: LogEntry): string {
const tag = ` [t${e.turn.toString().padStart(2, "0")} ${e.role.padEnd(8)} ${e.kind.padEnd(14)}]`;
const c = e.content ?? {};
const trim = (s: any, n: number) => String(s ?? "").slice(0, n);
if (e.kind === "tool_call") return `${tag} ${c.tool}(${JSON.stringify(c.args ?? {}).slice(0, 60)}) — ${trim(c.rationale, 40)}`;
if (e.kind === "tool_result") {
if (c.error) return `${tag} ERROR ${c.error}`;
const rows = c?.rows?.length ?? c?.sources?.length ?? undefined;
return `${tag} ${rows !== undefined ? `rows=${rows}` : JSON.stringify(c).slice(0, 60)}`;
}
if (e.kind === "critique") return `${tag} verdict=${c.verdict} — ${trim(c.notes, 50)}`;
if (e.kind === "propose_done") return `${tag} ${c.fills?.length ?? 0} fills: ${(c.fills ?? []).map((f: Fill) => f.name).join(", ")}`;
if (e.kind === "consensus_done") return `${tag} ✓`;
if (e.kind === "plan") return `${tag} ${c.steps?.length ?? 0} steps`;
if (e.kind === "error") return `${tag} ${c.message ?? c}`;
return `${tag} ${JSON.stringify(c).slice(0, 70)}`;
}
async function executeToolCall(name: string, args: Record<string, any>): Promise<any> {
if (name === "hybrid_search") {
const { sql_filter, question, index_name, k } = args;
if (!sql_filter || !question || !index_name) {
throw new Error(`hybrid_search needs sql_filter + question + index_name, got ${JSON.stringify(args)}`);
}
// Every fill event uses the playbook_memory boost — that's the point
// of the run-as-a-whole: earlier events seed later ones.
return httpJson(`${GATEWAY}/vectors/hybrid`, {
sql_filter, question, index_name,
top_k: k ?? 10, generate: false,
use_playbook_memory: true,
playbook_memory_k: 10,
});
}
if (name === "sql") {
const { query } = args;
if (!query || typeof query !== "string") throw new Error(`sql needs query string`);
if (!/^\s*SELECT/i.test(query)) throw new Error(`sql allows SELECT only`);
return sqlQuery(query);
}
return callTool(name, args);
}
// =================== Core fill loop — one event, one consensus ===================
interface AgentFillOutcome {
fills: Fill[];
approach: string;
turns: number;
duration_secs: number;
log: LogEntry[];
first_sql_matches?: number;
first_pool_first_score?: number;
first_pool_last_score?: number;
playbook_citations: string[];
}
async function runAgentFill(
task: TaskSpec,
extra_guidance: string,
exclude_worker_ids: string[],
): Promise<AgentFillOutcome> {
const t0 = Date.now();
const log: LogEntry[] = [];
let turn = 0;
let consecutiveDrifts = 0;
let sealed: { fills: Fill[]; approach: string } | null = null;
let first_sql_matches: number | undefined;
let first_pool_first: number | undefined;
let first_pool_last: number | undefined;
const playbook_citations = new Set<string>();
const append = (e: Omit<LogEntry, "at">): LogEntry => {
const full: LogEntry = { ...e, at: new Date().toISOString() };
log.push(full);
console.log(fmt(full));
return full;
};
// Build executor prompt with the scenario-specific guidance + exclusions
// injected as an extra block. Reuses the base prompt so drift detection
// and output-shape rules are unchanged.
const withExtras = (base: string): string => {
let addon = "";
if (extra_guidance) addon += `\n\nEVENT-SPECIFIC GUIDANCE:\n${extra_guidance}`;
if (exclude_worker_ids.length > 0) {
addon += `\n\nEXCLUDE these workers (already booked / unavailable today): ${exclude_worker_ids.join(", ")}\nIf your tool results include them, skip them — never propose them.`;
}
return base + addon;
};
while (turn < MAX_TURNS && !sealed) {
turn += 1;
const execRaw = await generate(
EXECUTOR_MODEL,
withExtras(executorPrompt(task, log)),
{ temperature: 0.2, max_tokens: 600 },
);
let execAction: Action;
try {
execAction = parseAction(execRaw, "executor");
} catch (e) {
append({ turn, role: "executor", model: EXECUTOR_MODEL, kind: "error",
content: { message: (e as Error).message, raw: execRaw.slice(0, 300) } });
throw e;
}
append({ turn, role: "executor", model: EXECUTOR_MODEL,
kind: execAction.kind as any, content: execAction });
if (execAction.kind === "tool_call") {
try {
const result = await executeToolCall(execAction.tool, execAction.args);
// Filter tool results to enforce the exclusion list — defense in
// depth since the prompt alone isn't enough for weak models.
const filtered = maskExclusions(result, exclude_worker_ids);
// Capture the first hybrid_search pool stats for gap detection.
if (execAction.tool === "hybrid_search" && first_sql_matches === undefined) {
first_sql_matches = (filtered as any).sql_matches;
const sources = (filtered as any).sources ?? [];
if (sources.length > 0) {
first_pool_first = sources[0].score;
first_pool_last = sources[sources.length - 1].score;
}
}
const trimmed = trimResult(filtered);
append({ turn, role: "executor", model: EXECUTOR_MODEL,
kind: "tool_result", content: trimmed });
// Accumulate playbook citations from any hybrid result that
// carried them — the scenario-level report needs them.
if (Array.isArray((filtered as any).sources)) {
for (const s of (filtered as any).sources) {
for (const c of s.playbook_citations ?? []) {
playbook_citations.add(c);
}
}
}
} catch (e) {
append({ turn, role: "executor", model: EXECUTOR_MODEL, kind: "tool_result",
content: { error: (e as Error).message, tool: execAction.tool } });
consecutiveDrifts += 1;
if (consecutiveDrifts >= MAX_CONSECUTIVE_DRIFTS) {
throw new Error(`aborted — ${MAX_CONSECUTIVE_DRIFTS} consecutive tool errors`);
}
}
}
const revRaw = await generate(
REVIEWER_MODEL,
withExtras(reviewerPrompt(task, log)),
{ temperature: 0.1, max_tokens: 400 },
);
let revAction: Action;
try {
revAction = parseAction(revRaw, "reviewer");
} catch (e) {
append({ turn, role: "reviewer", model: REVIEWER_MODEL, kind: "error",
content: { message: (e as Error).message, raw: revRaw.slice(0, 300) } });
throw e;
}
append({ turn, role: "reviewer", model: REVIEWER_MODEL,
kind: "critique", content: revAction });
if (revAction.kind !== "critique") throw new Error(`reviewer emitted non-critique: ${revAction.kind}`);
if (revAction.verdict === "drift") {
consecutiveDrifts += 1;
if (consecutiveDrifts >= MAX_CONSECUTIVE_DRIFTS) {
throw new Error(`aborted — ${MAX_CONSECUTIVE_DRIFTS} consecutive drift flags`);
}
} else {
consecutiveDrifts = 0;
}
if (execAction.kind === "propose_done" && revAction.verdict === "approve_done") {
if (execAction.fills.length !== task.target_count) {
throw new Error(`consensus malformed — ${execAction.fills.length} fills vs target ${task.target_count}`);
}
// Enforce exclusion at seal time too, in case the models ignored
// both prompt + tool-result filtering.
for (const f of execAction.fills) {
if (exclude_worker_ids.includes(f.candidate_id)) {
throw new Error(`consensus proposed excluded worker ${f.candidate_id}`);
}
}
append({ turn, role: "reviewer", model: REVIEWER_MODEL, kind: "consensus_done",
content: { fills: execAction.fills } });
sealed = { fills: execAction.fills, approach: execAction.rationale ?? "multi-agent hybrid" };
}
}
if (!sealed) throw new Error(`no consensus after ${MAX_TURNS} turns`);
return {
fills: sealed.fills,
approach: sealed.approach,
turns: turn,
duration_secs: (Date.now() - t0) / 1000,
log,
first_sql_matches,
first_pool_first_score: first_pool_first,
first_pool_last_score: first_pool_last,
playbook_citations: Array.from(playbook_citations),
};
}
function maskExclusions(result: any, exclude: string[]): any {
if (exclude.length === 0) return result;
if (Array.isArray(result.sources)) {
return { ...result, sources: result.sources.filter((s: any) => !exclude.includes(s.doc_id)) };
}
if (Array.isArray(result.rows)) {
return { ...result, rows: result.rows.filter((r: any) => {
const id = r.worker_id ?? r.doc_id;
return id === undefined || !exclude.includes(String(id));
}) };
}
return result;
}
function trimResult(r: any): any {
if (r && Array.isArray(r.sources)) {
return { ...r, sources: r.sources.slice(0, 20), _trimmed: r.sources.length > 20 ? `${r.sources.length - 20} more` : undefined };
}
if (r && Array.isArray(r.rows)) {
return { ...r, rows: r.rows.slice(0, 20), _trimmed: r.rows.length > 20 ? `${r.rows.length - 20} more` : undefined };
}
return r;
}
// =================== Per-event guidance strings ===================
function guidanceFor(event: FillEvent, ctx: ScenarioContext): string {
switch (event.kind) {
case "baseline_fill":
return `Standard Monday fill. Client ${ctx.spec.client}. Shift starts ${event.shift_start ?? "at start time"}. Take the top candidates by semantic match and availability.`;
case "recurring":
return `RECURRING slot — ${ctx.spec.client} runs this shift every Tues/Thurs. If playbook_memory surfaces candidates endorsed by past similar fills (you'll see 'cites' on hybrid sources), those are the preferred workers. Shift starts ${event.shift_start ?? "at start time"}.`;
case "expansion":
return `EXPANSION at ${ctx.spec.client}. New location, ${event.count}-worker team needed at once — search broadly and prefer workers with team/collaboration signals (engagement, communications scores). Shift starts ${event.shift_start ?? "at start time"}.`;
case "emergency":
return `EMERGENCY walkoff — ${ctx.spec.client} needs ${event.count} ${event.role}s BY ${event.deadline ?? "end of day"}. Prioritize availability over perfect skill match. A good-enough worker who can report today beats a perfect worker who can't.`;
case "misplacement":
return `MISPLACEMENT refill. A worker from the 08:00 shift no-showed. You must replace them WITHOUT proposing the same worker or anyone already booked today (see EXCLUDE list). Shift is ${event.shift_start ?? "in progress"} so speed matters.`;
}
}
// =================== Artifact generation ===================
interface ArtifactBundle {
sms: string;
email: string;
}
// One Ollama call per event for SMS (to the filled workers) + one for
// the client email. Short outputs, low temperature — these are drafts,
// not creative writing.
async function generateArtifacts(event: FillEvent, outcome: AgentFillOutcome, ctx: ScenarioContext): Promise<ArtifactBundle> {
const smsPrompt = `Generate short, friendly, professional SMS messages to confirm a shift for each worker. ONE message per worker. Format as:
TO: {Name}
{message body under 180 chars}
---
Details:
- Client: ${ctx.spec.client}
- Role: ${event.role}
- Location: ${event.city}, ${event.state}
- Shift starts: ${event.shift_start ?? "TBD"}
- Scenario: ${event.scenario_note ?? ""}
Workers to message:
${outcome.fills.map(f => `- ${f.name} (id ${f.candidate_id})`).join("\n")}
Respond with only the message blocks, separated by "---". No commentary.`;
const emailPrompt = `Generate a short professional email confirmation to the staffing client.
TO: staffing@${ctx.spec.client.toLowerCase().replace(/ /g, "")}.example
FROM: dispatch@lakehouse.example
SUBJECT: (3-word subject)
Body (4-6 lines max). Be specific about:
- Number of workers filled (${outcome.fills.length} of ${event.count})
- Roles: ${event.role}
- Names filled
- Shift start: ${event.shift_start ?? "TBD"}
- Any scenario flag: ${event.scenario_note ?? "(none)"}
Workers:
${outcome.fills.map(f => `- ${f.name} (${f.reason.slice(0, 60)})`).join("\n")}
Respond with only the email. No commentary.`;
const [sms, email] = await Promise.all([
generate(DRAFT_MODEL, smsPrompt, { temperature: 0.3, max_tokens: 500 }),
generate(DRAFT_MODEL, emailPrompt, { temperature: 0.3, max_tokens: 400 }),
]);
return { sms: sms.trim(), email: email.trim() };
}
// =================== Per-event runner ===================
async function runEvent(event: FillEvent, ctx: ScenarioContext): Promise<EventResult> {
console.log(`\n════════ ${event.at} — ${event.kind.toUpperCase()}: fill ${event.count}× ${event.role} in ${event.city}, ${event.state} ════════`);
const t0 = Date.now();
// Build the task spec the agent loop expects.
const task: TaskSpec = {
id: `${ctx.spec.date}-${event.at.replace(":", "")}-${event.kind}`,
operation: `fill: ${event.role} x${event.count} in ${event.city}, ${event.state}`,
target_role: event.role,
target_count: event.count,
target_city: event.city,
target_state: event.state,
approach_hint: `hybrid search against ${WORKERS_INDEX} for ${event.kind}`,
};
// Exclusion set: everyone already in today's roster + any explicit
// exclusions from the event spec.
const excludeIds = [
...ctx.roster
.filter(r => r.status === "confirmed")
.map(r => r.worker_id),
...(event.exclude_worker_ids ?? []),
];
const gap_signals: string[] = [];
let outcome: AgentFillOutcome;
try {
outcome = await runAgentFill(task, guidanceFor(event, ctx), excludeIds);
} catch (e) {
return {
event,
ok: false,
fills: [],
turns: 0,
duration_secs: (Date.now() - t0) / 1000,
error: (e as Error).message,
gap_signals: [`drift_or_tool: ${(e as Error).message}`],
};
}
// Resolve worker_ids via SQL so the roster has stable IDs (models
// sometimes return names-only). Best-effort — if name lookup finds
// zero or many matches, we flag a gap.
const resolved = await resolveWorkerIds(outcome.fills, event);
// Roster double-book check.
for (const r of resolved) {
const conflict = ctx.roster.find(e => e.worker_id === r.worker_id && e.status === "confirmed");
if (conflict) {
gap_signals.push(`double_book: ${r.worker_id} ${r.name} already booked for ${conflict.booked_for}`);
}
ctx.roster.push({
worker_id: r.worker_id,
name: r.name,
booked_for: event.at,
role: event.role,
city: event.city,
state: event.state,
status: "confirmed",
});
}
// Pool-size signal (Gap 1 — supply).
const supply_threshold = event.count * 3;
if ((outcome.first_sql_matches ?? 0) < supply_threshold) {
gap_signals.push(
`supply: only ${outcome.first_sql_matches} candidates for ${event.count}× ${event.role} in ${event.city} (< ${supply_threshold}, our 3× comfort margin)`
);
}
// Score-spread signal (Gap 2 — embedding).
const spread = (outcome.first_pool_first_score ?? 0) - (outcome.first_pool_last_score ?? 0);
if (spread > 0 && spread < 0.02) {
gap_signals.push(
`embedding: top-K score spread ${spread.toFixed(3)} < 0.02 — model struggles to differentiate`
);
}
// Generate artifacts (SMS + email) — fail-soft; artifacts are cosmetic
// relative to the consensus itself.
let bundle: ArtifactBundle | null = null;
try {
bundle = await generateArtifacts(event, { ...outcome, fills: resolved }, ctx);
await appendFile(join(ctx.out_dir, "sms.md"),
`\n## ${event.at} ${event.kind} — ${event.role} x${event.count} in ${event.city}, ${event.state}\n\n${bundle.sms}\n`);
await appendFile(join(ctx.out_dir, "emails.md"),
`\n## ${event.at} ${event.kind} — ${event.role} x${event.count}\n\n${bundle.email}\n`);
} catch (e) {
gap_signals.push(`artifact: ${(e as Error).message}`);
}
// Dispatch log (structured).
await appendFile(join(ctx.out_dir, "dispatch.jsonl"),
JSON.stringify({
at: event.at,
kind: event.kind,
operation: task.operation,
fills: resolved,
turns: outcome.turns,
duration_secs: outcome.duration_secs,
pool_size: outcome.first_sql_matches,
playbook_citations: outcome.playbook_citations,
}) + "\n");
// Always seed playbook_memory after a sealed fill — keep the learning
// loop tight across the whole day so recurring/misplacement events
// later in the run benefit from earlier ones.
try {
await httpJson(`${GATEWAY}/vectors/playbook_memory/seed`, {
operation: task.operation,
approach: outcome.approach || `${event.kind} → hybrid search`,
context: `client=${ctx.spec.client} scenario=${event.kind} shift=${event.shift_start ?? "tbd"}`,
endorsed_names: resolved.map(r => r.name),
append: true,
});
} catch (e) {
gap_signals.push(`write_through: ${(e as Error).message}`);
}
return {
event,
ok: true,
fills: outcome.fills,
turns: outcome.turns,
duration_secs: outcome.duration_secs,
gap_signals,
sources_first_score: outcome.first_pool_first_score,
sources_last_score: outcome.first_pool_last_score,
pool_size: outcome.first_sql_matches,
playbook_citations: outcome.playbook_citations,
};
}
// =================== Worker ID resolution ===================
// Models emit candidate_ids or names in propose_done. Some return the
// W500K-XXX doc_id, others just the name, others a random tag. Resolve
// to canonical (worker_id, name) via SQL so the roster is reliable.
async function resolveWorkerIds(fills: Fill[], event: FillEvent): Promise<Fill[]> {
const resolved: Fill[] = [];
for (const f of fills) {
// Case 1: candidate_id looks like W500K-NNN — accept as-is.
if (/^W500K-\d+$/.test(f.candidate_id)) {
resolved.push(f);
continue;
}
// Case 2: candidate_id is a bare integer — promote to W500K-N.
if (/^\d+$/.test(f.candidate_id)) {
resolved.push({ ...f, candidate_id: `W500K-${f.candidate_id}` });
continue;
}
// Case 3: look up by (name, city, state). Take the first match.
const q = `SELECT worker_id FROM ${WORKERS_DATASET} WHERE name = '${f.name.replace(/'/g, "''")}' AND city = '${event.city.replace(/'/g, "''")}' AND state = '${event.state.replace(/'/g, "''")}' LIMIT 1`;
try {
const r = await sqlQuery(q);
if (r.rows && r.rows.length > 0) {
resolved.push({ ...f, candidate_id: `W500K-${r.rows[0].worker_id}` });
} else {
// No match — keep the fill but leave candidate_id as-is; the
// gap report will flag it.
resolved.push(f);
}
} catch {
resolved.push(f);
}
}
return resolved;
}
// =================== EOD gap report ===================
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}\``);
lines.push("");
// --- Per-event summary ---
lines.push("## Events");
lines.push("");
lines.push("| At | Kind | Role / Count | Pool | Fills | Turns | Dur(s) | Cites | Gaps |");
lines.push("|---|---|---|---|---|---|---|---|---|");
for (const r of ctx.results) {
const status = r.ok ? "✓" : "✗";
lines.push(
`| ${r.event.at} | ${r.event.kind} | ${r.event.role} × ${r.event.count} | ${r.pool_size ?? "-"} | ${status} ${r.fills.length} | ${r.turns} | ${r.duration_secs.toFixed(1)} | ${r.playbook_citations?.length ?? 0} | ${r.gap_signals.length} |`
);
}
lines.push("");
// --- Roster ---
lines.push("## Final roster");
lines.push("");
lines.push("| Worker | Booked | Role | City, ST | Status |");
lines.push("|---|---|---|---|---|");
for (const e of ctx.roster) {
lines.push(`| ${e.worker_id} ${e.name} | ${e.booked_for} | ${e.role} | ${e.city}, ${e.state} | ${e.status} |`);
}
lines.push("");
// --- Gap analysis by category ---
const bycat: Record<string, string[]> = {};
for (const g of ctx.gap_signals) {
if (!bycat[g.category]) bycat[g.category] = [];
bycat[g.category].push(`**${g.event}** — ${g.detail}`);
}
// Add cross-event categories computed here:
// Gap 3 — fairness (Gini-lite on roster)
const bookedIds = ctx.roster.filter(r => r.status === "confirmed").map(r => r.worker_id);
const counts = new Map<string, number>();
for (const id of bookedIds) counts.set(id, (counts.get(id) ?? 0) + 1);
const multis = [...counts.entries()].filter(([_, n]) => n > 1);
if (multis.length > 0) {
bycat["fairness"] = bycat["fairness"] ?? [];
for (const [id, n] of multis) {
const name = ctx.roster.find(r => r.worker_id === id)?.name ?? id;
bycat["fairness"].push(`_cross-event_ — ${name} (${id}) booked ${n} times today`);
}
}
// Gap 5 — tool errors already captured per-event via gap_signals.
// Gap 6 — write-through coverage: compare # events vs # new playbook_memory entries.
try {
const stats = await httpJson<any>(`${GATEWAY}/vectors/playbook_memory/stats`);
bycat["write_through_audit"] = bycat["write_through_audit"] ?? [];
bycat["write_through_audit"].push(`_post-run_ — playbook_memory has ${stats.entries} entries (ran ${ctx.results.length} events, expected ≥ ${ctx.results.filter(r => r.ok).length} new entries from this run)`);
} catch { /* non-fatal */ }
lines.push("## Gap signals");
lines.push("");
if (Object.keys(bycat).length === 0) {
lines.push("_None surfaced — either everything worked or detection is under-tuned._");
} else {
for (const [cat, items] of Object.entries(bycat)) {
lines.push(`### ${cat}`);
for (const item of items) lines.push(`- ${item}`);
lines.push("");
}
}
// --- Narrative summary ---
lines.push("## Narrative");
lines.push("");
lines.push(`- ${ctx.results.filter(r => r.ok).length}/${ctx.results.length} events reached consensus.`);
lines.push(`- Final roster: ${ctx.roster.length} bookings across ${new Set(ctx.roster.map(r => r.worker_id)).size} distinct workers.`);
const totalCites = ctx.results.reduce((a, r) => a + (r.playbook_citations?.length ?? 0), 0);
lines.push(`- Playbook citations across the day: ${totalCites} (proof the feedback loop fired across events).`);
const droppedEvents = ctx.results.filter(r => !r.ok);
if (droppedEvents.length > 0) {
lines.push(`- Dropped events: ${droppedEvents.map(r => r.event.at + " " + r.event.kind).join(", ")}.`);
}
await writeFile(join(ctx.out_dir, "report.md"), lines.join("\n"));
console.log(`\n✓ report → ${join(ctx.out_dir, "report.md")}`);
}
// =================== Main driver ===================
async function main() {
const specPath = process.argv[2];
const spec: ScenarioSpec = specPath
? JSON.parse(await Bun.file(specPath).text())
: DEFAULT_SCENARIO;
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const out_dir = join("tests/multi-agent/playbooks", `scenario-${stamp}`);
await mkdir(out_dir, { recursive: true });
const ctx: ScenarioContext = {
spec,
out_dir,
roster: [],
results: [],
gap_signals: [],
};
// Initialize output files
await writeFile(join(out_dir, "sms.md"), `# SMS drafts — ${spec.client}, ${spec.date}\n`);
await writeFile(join(out_dir, "emails.md"), `# Client emails — ${spec.client}, ${spec.date}\n`);
await writeFile(join(out_dir, "dispatch.jsonl"), "");
console.log(`▶ scenario: ${spec.client}, ${spec.date}, ${spec.events.length} events`);
console.log(`▶ out: ${out_dir}\n`);
for (const event of spec.events) {
// Expand misplacement-style exclusions from the current roster: it
// wants to replace a worker from a prior event, so grab everyone
// booked at that at-label and add as exclusions.
if (event.kind === "misplacement" && event.replaces_event) {
const priorBooked = ctx.roster
.filter(r => r.booked_for === event.replaces_event && r.status === "confirmed")
.map(r => r.worker_id);
if (priorBooked.length > 0) {
// Pick one arbitrarily to mark as no_show — in a real system the
// external signal would pick. For the test, first one works.
const lost = priorBooked[0];
const lostEntry = ctx.roster.find(r => r.worker_id === lost);
if (lostEntry) {
lostEntry.status = "no_show";
console.log(` (misplacement: marking ${lost} ${lostEntry.name} as no-show)`);
}
// Exclude all prior bookings so the refill doesn't pick anyone
// already scheduled for today.
event.exclude_worker_ids = priorBooked;
}
}
const result = await runEvent(event, ctx);
ctx.results.push(result);
for (const s of result.gap_signals) {
const [category, ...rest] = s.split(":");
ctx.gap_signals.push({ event: event.at, category: category.trim(), detail: rest.join(":").trim() });
}
// Small breather to not hammer Ollama on back-to-back runs.
await new Promise(r => setTimeout(r, 500));
}
// Persist structured state for forensics.
await writeFile(join(out_dir, "roster.json"), JSON.stringify(ctx.roster, null, 2));
await writeFile(join(out_dir, "results.json"), JSON.stringify(ctx.results, null, 2));
await writeRetrospective(ctx);
const okCount = ctx.results.filter(r => r.ok).length;
if (okCount < ctx.results.length) {
console.log(`\n⚠ ${okCount}/${ctx.results.length} events succeeded. See ${out_dir}/report.md for gaps.`);
process.exit(2);
}
console.log(`\n✓ ${okCount}/${ctx.results.length} events succeeded. See ${out_dir}/report.md.`);
process.exit(0);
}
main().catch(e => {
console.error(`\n✗ scenario driver crashed: ${(e as Error).message}`);
console.error((e as Error).stack);
process.exit(1);
});