From 90006002f8b0c26d02772e11c428cbfc29a0daee Mon Sep 17 00:00:00 2001 From: test Date: Thu, 13 Aug 2026 12:11:48 +0200 Subject: [PATCH 1/2] feat(loki): ground every answer in typed records instead of prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loki answered a five-part "plan my day" prompt with three fabricated answers, then produced an unverified correction when challenged. Traced: - "rotate the expired GROQ_API_KEY, it blocks truthseeker" — the key returns HTTP 200. Both cited .env paths were invented. - "Ilya Druzhnikov (UZH)" — the record has no org field and "UZH" exists nowhere in the operator's data. It is the substring inside dr-UZH-nikov: a keyword-match artifact narrated as an affiliation. - "Elena Weber, Accelerator & Bridge Program Manager, University of Liechtenstein alumna" — the record is a display name and a phone number. No web search was performed. Root cause is architectural, not prompt quality. buildLokiFleetContext injected projects + RAG only; people, goals, habits, commitments and events were never in context, though FleetCrown could already answer every one of those questions exactly (getStuckGoals, getGoalsDueSoon, listUpcomingCommitments, getEventsDueSoon have existed for months). So a prompt demanding five items met a context supporting one, and the model filled the rest — the ordinary failure mode of a small model under format pressure, which is precisely who will be running this. The fix makes unsupported claims hard to EXPRESS, not just discouraged: agent/core/facts.ts records with a declared field set; unstored fields render as an explicit ``, turning absence into a stated negative agent/core/contract.ts rules generated from THIS turn's facts — enumerated citation ids, named concrete gaps, a verbatim refusal phrase; plus Directive for answers computed in SQL that the model may only phrase, never re-derive agent/core/verify.ts deterministic post-generation check. No extra model call, so it runs on free-tier turns too. Flags unresolvable citations and proper nouns / numbers / paths absent from the records. agent/brief.ts wires the existing SQL into the assistant at last; an empty result is reported as empty agent/sources.ts people (new), projects, pgvector docs, and OrangeCat demand → Facts agent/context.ts assembly; contract first, records last loki-core now verifies each answer and gives the model one repair pass to DELETE unsupported claims. Surviving violations ride back as `grounding` metadata rather than being swallowed: the original failure was not that Loki was wrong, it was that wrong looked exactly like right. scripts/test/agent-grounding.ts asserts all four fabrications above are caught and that a clean grounded answer still passes. core/ is mirrored to OrangeCat with a SHA-256 drift gate, pending @fleet/agent-core. Removes loki-fleet-context/-index — superseded, and a second definition of the fleet index would be the SSOT violation this replaces. Co-Authored-By: Claude Opus 5 --- package.json | 3 +- scripts/sync-agent-core.ts | 41 +++++ scripts/test/agent-core-drift.ts | 61 +++++++ scripts/test/agent-grounding.ts | 217 ++++++++++++++++++++++ scripts/test/loki-fleet-context.ts | 42 ----- src/lib/agent/brief.ts | 121 +++++++++++++ src/lib/agent/context.ts | 96 ++++++++++ src/lib/agent/core/README.md | 74 ++++++++ src/lib/agent/core/contract.ts | 132 ++++++++++++++ src/lib/agent/core/facts.ts | 165 +++++++++++++++++ src/lib/agent/core/verify.ts | 279 +++++++++++++++++++++++++++++ src/lib/agent/sources.ts | 201 +++++++++++++++++++++ src/lib/loki-core.ts | 121 +++++++++++-- src/lib/loki-fleet-context.ts | 102 ----------- src/lib/loki-fleet-index.ts | 34 ---- 15 files changed, 1496 insertions(+), 193 deletions(-) create mode 100644 scripts/sync-agent-core.ts create mode 100644 scripts/test/agent-core-drift.ts create mode 100644 scripts/test/agent-grounding.ts delete mode 100644 scripts/test/loki-fleet-context.ts create mode 100644 src/lib/agent/brief.ts create mode 100644 src/lib/agent/context.ts create mode 100644 src/lib/agent/core/README.md create mode 100644 src/lib/agent/core/contract.ts create mode 100644 src/lib/agent/core/facts.ts create mode 100644 src/lib/agent/core/verify.ts create mode 100644 src/lib/agent/sources.ts delete mode 100644 src/lib/loki-fleet-context.ts delete mode 100644 src/lib/loki-fleet-index.ts diff --git a/package.json b/package.json index a936f8868..c3ffcee47 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,8 @@ "doctor": "bash scripts/doctor.sh", "git:prune": "bash scripts/git/prune-merged.sh", "ship": "git push origin main", - "prepare": "husky" + "prepare": "husky", + "sync:agent-core": "tsx scripts/sync-agent-core.ts" }, "dependencies": { "@auth/drizzle-adapter": "^1.11.3", diff --git a/scripts/sync-agent-core.ts b/scripts/sync-agent-core.ts new file mode 100644 index 000000000..fa70ec76b --- /dev/null +++ b/scripts/sync-agent-core.ts @@ -0,0 +1,41 @@ +/** + * Push the canonical agent/core into OrangeCat's mirror. + * Run: npx tsx scripts/sync-agent-core.ts (npm run sync:agent-core) + * + * FleetCrown owns the canonical copy; OrangeCat mirrors it byte-for-byte. See + * src/lib/agent/core/README.md for why the harness is duplicated rather than + * packaged, and scripts/test/agent-core-drift.ts for the check that makes the + * duplication safe. + * + * Deliberately a no-op when the sibling repo is absent — CI clones one repo at + * a time, and a sync script that fails there would block every unrelated build. + */ +import { readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SRC = join(HERE, "..", "src", "lib", "agent", "core"); +const DEST = process.env.ORANGECAT_DIR + ? join(process.env.ORANGECAT_DIR, "src", "services", "agent-core") + : join(HERE, "..", "..", "orangecat", "src", "services", "agent-core"); + +if (!existsSync(dirname(dirname(DEST)))) { + console.log(`↷ OrangeCat not found at ${DEST} — skipping mirror (set ORANGECAT_DIR to override)`); + process.exit(0); +} + +mkdirSync(DEST, { recursive: true }); + +let changed = 0; +for (const file of readdirSync(SRC).sort()) { + const body = readFileSync(join(SRC, file), "utf8"); + const target = join(DEST, file); + const current = existsSync(target) ? readFileSync(target, "utf8") : null; + if (current === body) continue; + writeFileSync(target, body); + console.log(` → ${file}`); + changed++; +} + +console.log(changed === 0 ? "✓ agent-core mirror already in sync" : `✓ mirrored ${changed} file(s) to ${DEST}`); diff --git a/scripts/test/agent-core-drift.ts b/scripts/test/agent-core-drift.ts new file mode 100644 index 000000000..4cea5c566 --- /dev/null +++ b/scripts/test/agent-core-drift.ts @@ -0,0 +1,61 @@ +/** + * Drift gate for the mirrored agent/core. + * Run: npx tsx scripts/test/agent-core-drift.ts + * + * The harness is duplicated into OrangeCat rather than packaged (see + * src/lib/agent/core/README.md). Duplication is only safe if divergence is + * impossible to commit accidentally — two copies of "what counts as grounded", + * quietly disagreeing, is a worse failure than the one the harness was built to + * fix, because it would make the two assistants wrong in different ways. + * + * So: SHA-256 per file, compared against the mirror. Any difference fails. + * + * SKIPS (exit 0) when OrangeCat is not checked out beside this repo, because CI + * clones one repo at a time. That means the gate is a LOCAL and pre-push + * guarantee, not a CI one — the honest boundary, stated rather than implied. + * The corresponding check on OrangeCat's side is what catches a mirror edited + * in isolation. + */ +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SRC = join(HERE, "..", "..", "src", "lib", "agent", "core"); +const MIRROR = process.env.ORANGECAT_DIR + ? join(process.env.ORANGECAT_DIR, "src", "services", "agent-core") + : join(HERE, "..", "..", "..", "orangecat", "src", "services", "agent-core"); + +const sha = (s: string) => createHash("sha256").update(s).digest("hex").slice(0, 16); + +if (!existsSync(MIRROR)) { + console.log(`↷ agent-core drift: OrangeCat mirror not present at ${MIRROR} — skipped`); + process.exit(0); +} + +const srcFiles = readdirSync(SRC).sort(); +const mirrorFiles = readdirSync(MIRROR).sort(); +const problems: string[] = []; + +for (const f of srcFiles) { + if (!mirrorFiles.includes(f)) { + problems.push(`missing from mirror: ${f}`); + continue; + } + const a = sha(readFileSync(join(SRC, f), "utf8")); + const b = sha(readFileSync(join(MIRROR, f), "utf8")); + if (a !== b) problems.push(`content differs: ${f} (canonical ${a} vs mirror ${b})`); +} +for (const f of mirrorFiles) { + if (!srcFiles.includes(f)) problems.push(`extra file in mirror (not canonical): ${f}`); +} + +if (problems.length > 0) { + console.error("✗ agent-core drift detected:"); + for (const p of problems) console.error(` ${p}`); + console.error("\n Fix: edit the FleetCrown copy, then run `npm run sync:agent-core`."); + process.exit(1); +} + +console.log(`✓ agent-core drift: ${srcFiles.length} file(s) identical across both repos`); diff --git a/scripts/test/agent-grounding.ts b/scripts/test/agent-grounding.ts new file mode 100644 index 000000000..84c3f065c --- /dev/null +++ b/scripts/test/agent-grounding.ts @@ -0,0 +1,217 @@ +/** + * Adversarial groundedness suite for the agent harness. + * Run: npx tsx scripts/test/agent-grounding.ts + * + * Every case here is taken VERBATIM from a real Loki failure (2026-08-13), in + * which Loki answered a five-part "plan my day" prompt with three fabricated + * answers and then produced a fabricated correction when challenged. The + * fabrications were not exotic — they are the ordinary failure mode of a model + * asked to fill a rigid format against thin context, which is exactly what a + * small/free model does most. + * + * The traced ground truth for those claims: + * - "rotate the expired GROQ_API_KEY, it blocks truthseeker" — the key + * returned HTTP 200 when tested. Both cited .env paths were invented. + * - "Ilya Druzhnikov (UZH)" — the record has no org field and the string + * "UZH" exists nowhere in the operator's data. It is the substring inside + * dr-UZH-nikov, i.e. a keyword-match artifact. + * - "Elena Weber — Accelerator & Bridge Program Manager, University of + * Liechtenstein alumna, START Summit jury" — the record is a display name + * and a phone number. Nothing else. No web search was performed. + * + * These assertions are the contract: if the harness stops catching them, the + * product has regressed to the state that produced that transcript. + */ +import assert from "node:assert/strict"; +import { + makeFact, + assignFactIds, + renderFacts, + unrecordedFields, + NOT_RECORDED, +} from "../../src/lib/agent/core/facts"; +import { buildContract, buildGroundedContext, renderDirectives, NO_BASIS } from "../../src/lib/agent/core/contract"; +import { verifyAnswer, buildRepairPrompt } from "../../src/lib/agent/core/verify"; + +// ── The real records, exactly as FleetCrown stores them ────────────────────── +const FACTS = assignFactIds([ + makeFact({ + kind: "person", + subject: "Elena Weber SINGA Switzerland", + source: "people table", + values: { name: "Elena Weber SINGA Switzerland", channels: "whatsapp +41774730093" }, + }), + makeFact({ + kind: "person", + subject: "Ilya Druzhnikov", + source: "people table", + values: { name: "Ilya Druzhnikov", channels: "whatsapp +16508620988" }, + }), + makeFact({ + kind: "project", + subject: "truthseeker", + source: "projects table", + values: { name: "truthseeker", status: "active", stack: "TypeScript" }, + }), +]); + +const USER_MSG = "Plan my day. Who should I reach out to and why?"; + +// ── 1. Absence is rendered explicitly, not omitted ─────────────────────────── +{ + const rendered = renderFacts(FACTS); + assert.match(rendered, /affiliation: /, "affiliation must render as an explicit gap"); + assert.match(rendered, /role: /, "role must render as an explicit gap"); + assert.match(rendered, /channels: whatsapp \+41774730093/, "stored values must survive rendering"); + assert.equal(NOT_RECORDED, ""); + + const gaps = unrecordedFields(FACTS); + assert.ok(gaps.includes("person.affiliation"), "affiliation gap must be reported to the contract"); + assert.ok(gaps.includes("person.role"), "role gap must be reported to the contract"); +} + +// ── 2. The contract names this turn's citations and gaps concretely ────────── +{ + const contract = buildContract(FACTS); + assert.match(contract, /\[F1\] \[F2\] \[F3\]/, "legal citation ids must be enumerated"); + assert.match(contract, /person\.affiliation/, "the contract must name the concrete gap"); + assert.match(contract, /A surname is not an employer/, "the anti-UZH rule must be stated"); + assert.ok(contract.includes(NO_BASIS), "the refusal phrase must be supplied verbatim"); + + // Empty retrieval must produce the strictest contract, not a permissive one. + const empty = buildContract([]); + assert.match(empty, /NO records were retrieved/, "empty context must be stated, not implied"); +} + +// ── 3. The UZH fabrication is caught ───────────────────────────────────────── +{ + const r = verifyAnswer({ + answer: "**Ilya Druzhnikov (UZH)** — Academic/research contact at University of Zurich. [F2]", + facts: FACTS, + userMessage: USER_MSG, + }); + assert.equal(r.ok, false, "the UZH claim must be rejected"); + const flagged = r.violations.map((v) => v.text.toLowerCase()).join(" "); + assert.match(flagged, /uzh/, "UZH itself must be flagged as a novel proper noun"); + assert.ok( + r.violations.some((v) => /university of zurich/i.test(v.text)), + "the expanded affiliation must also be flagged", + ); +} + +// ── 4. The fabricated Elena Weber biography is caught ──────────────────────── +{ + const r = verifyAnswer({ + answer: [ + "**Elena Weber** is the **Accelerator & Bridge Program Manager** at SINGA Switzerland.", + "Background: University of Liechtenstein (START Alumna). Jury member at START Summit.", + "Direct contact: +41 77 473 00 93.", + ].join("\n"), + facts: FACTS, + userMessage: USER_MSG, + }); + assert.equal(r.ok, false, "the invented biography must be rejected"); + const texts = r.violations.map((v) => v.text); + assert.ok(texts.some((t) => /Accelerator/i.test(t)), "invented job title must be flagged"); + assert.ok(texts.some((t) => /Liechtenstein/i.test(t)), "invented alma mater must be flagged"); + assert.ok(texts.some((t) => /START Summit/i.test(t)), "invented jury role must be flagged"); + + // The genuinely-stored phone number must NOT be flagged, even though the + // model reformatted it with spaces. Digit-level comparison covers that. + assert.ok( + !texts.some((t) => t.replace(/\D/g, "") === "41774730093"), + "a real, stored phone number must pass even when reformatted", + ); +} + +// ── 5. The invented Groq remediation is caught ─────────────────────────────── +{ + const r = verifyAnswer({ + answer: [ + "**truthseeker** — rotate the expired GROQ_API_KEY.", + "2. Update key in /opt/fleetcrown/runner/.env and /opt/fleet-runner/.env.", + ].join("\n"), + facts: FACTS, + userMessage: USER_MSG, + }); + assert.equal(r.ok, false, "invented file paths must be rejected"); + assert.ok( + r.violations.some((v) => v.kind === "novel-path" && v.text.includes("/opt/fleetcrown/runner")), + "the fabricated .env path must be flagged as a path claim", + ); +} + +// ── 6. Fabricated CITATIONS are caught ─────────────────────────────────────── +{ + const r = verifyAnswer({ + answer: "Reach out to Elena Weber [F9] — she runs the accelerator [F12].", + facts: FACTS, + userMessage: USER_MSG, + }); + assert.ok( + r.violations.filter((v) => v.kind === "unknown-citation").length === 2, + "both citations to non-existent records must be flagged", + ); +} + +// ── 7. The CORRECTION path is verified too ─────────────────────────────────── +// When challenged, Loki produced a new assertion about the file's contents. +// It happened to be true, but it came from the same ungrounded process — the +// operator had no way to distinguish a real correction from a second +// fabrication. Corrections are claims and must clear the same bar. +{ + const bad = verifyAnswer({ + answer: "Correction: Ilya Druzhnikov is listed in data/contact-resolver.json with no UZH affiliation.", + facts: FACTS, + userMessage: "ilya is at uzh? where is this info coming from", + }); + assert.equal(bad.ok, false, "a correction citing an unprovided file must still be rejected"); +} + +// ── 8. A correct, grounded answer passes clean ─────────────────────────────── +// The check must not merely reject everything — an answer that stays inside the +// records has to survive, or the harness is a denial-of-service on itself. +{ + const good = verifyAnswer({ + answer: [ + `Reach out to Elena Weber SINGA Switzerland [F1] — whatsapp +41774730093.`, + `Why: ${NO_BASIS} No relationship or affiliation is recorded for this contact.`, + `Habit at risk: ${NO_BASIS}`, + ].join("\n"), + facts: FACTS, + userMessage: USER_MSG, + }); + assert.equal( + good.ok, + true, + `a fully grounded answer must pass, got: ${JSON.stringify(good.violations, null, 2)}`, + ); +} + +// ── 9. Computed answers are stated as settled, and empties survive ─────────── +{ + const block = renderDirectives([ + { question: "goals stuck at 0% for 30+ days", answer: [], method: "SQL: progress=0 AND updated_at < now()-30d" }, + { question: "commitments due in 3 days", answer: ["Ship harness — due 2026-08-15"], method: "SQL: due <= now()+3d" }, + ]); + assert.match(block, /do not re-derive/i, "computed answers must be marked non-negotiable"); + assert.match(block, /\(none — the query ran and matched nothing\)/, "an empty result must be explicit"); + assert.match(block, /Ship harness/, "a real computed result must render"); +} + +// ── 10. Assembly order: contract first, records last ───────────────────────── +{ + const ctx = buildGroundedContext({ facts: FACTS, renderedFacts: renderFacts(FACTS) }); + assert.ok( + ctx.indexOf("Grounding contract") < ctx.indexOf("## Records"), + "the contract must frame the records, not trail them", + ); + const repair = buildRepairPrompt( + verifyAnswer({ answer: "Ilya Druzhnikov (UZH)", facts: FACTS, userMessage: "" }).violations, + NO_BASIS, + ); + assert.match(repair, /Remove every unsupported claim/, "repair prompt must instruct deletion, not re-generation"); + assert.ok(repair.includes(NO_BASIS), "repair prompt must offer the refusal phrase as the substitute"); +} + +console.log("✓ agent grounding: 10 adversarial checks passed (UZH, invented bio, invented paths, fake citations, correction path)"); diff --git a/scripts/test/loki-fleet-context.ts b/scripts/test/loki-fleet-context.ts deleted file mode 100644 index 5785fa1c6..000000000 --- a/scripts/test/loki-fleet-context.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Unit test for Loki's fleet index (buildFleetIndex) — the BREADTH half of - * Loki's project awareness. Pure function, no DB, so it runs in the env- - * independent unit suite. The RAG/DEPTH half (buildLokiFleetContext) needs a - * live DB + embeddings and is exercised by the prod Loki dogfood instead. - */ -import assert from "node:assert/strict"; -import { buildFleetIndex } from "../../src/lib/loki-fleet-index"; -import type { UserProject, DevLogEntry } from "../../src/db/schema/user-projects"; - -function project(partial: Partial): UserProject { - return { - id: "p", userId: "u", name: "proj", description: null, stack: null, - isActive: true, devLog: [] as DevLogEntry[], - // remaining columns are irrelevant to the index; cast through unknown. - ...partial, - } as unknown as UserProject; -} - -// Empty fleet → empty string (nothing to inject). -assert.equal(buildFleetIndex([]), "", "empty fleet → empty string"); - -// A populated fleet leads with a count header and one line per project. -const out = buildFleetIndex([ - project({ name: "BiasLens", stack: "Next.js", description: "Detect media bias", devLog: [{ date: "2026-07-01", done: "shipped ingest", next: "UI" } as DevLogEntry] }), - project({ name: "orangecat", description: "Bitcoin-native funding" }), -]); -assert.match(out, /Your projects \(2 active\)/, "header shows active count"); -assert.match(out, /\*\*BiasLens\*\*/, "project name is bolded"); -assert.match(out, /Next\.js/, "stack is included when present"); -assert.match(out, /Detect media bias/, "description is included"); -assert.match(out, /latest: 2026-07-01: shipped ingest/, "latest dev-log state is surfaced"); -assert.match(out, /\*\*orangecat\*\*/, "second project present"); -assert.ok(!/undefined|null/.test(out), "no undefined/null leaks for missing fields"); - -// The import placeholder description is filtered (cleanDescription) — not echoed. -const placeholder = buildFleetIndex([ - project({ name: "ghost", description: "Local repository imported from fleetcrown-ui" }), -]); -assert.ok(!/Local repository imported/.test(placeholder), "placeholder description is suppressed"); - -console.log("✓ loki-fleet-context (buildFleetIndex) tests passed"); diff --git a/src/lib/agent/brief.ts b/src/lib/agent/brief.ts new file mode 100644 index 000000000..cbd6b1c4e --- /dev/null +++ b/src/lib/agent/brief.ts @@ -0,0 +1,121 @@ +/** + * The deterministic brief — computed answers Loki must not derive itself. + * + * The failure that motivated this: asked "which goals are stuck at 0% for 30+ + * days, what's due in the next 3 days, which habit am I most at risk of + * breaking", Loki answered all three from a context block that contained none + * of that data. It had projects and RAG chunks; it had no goals-with-dates, no + * commitments, no habits, no events. So it produced plausible items. + * + * The striking part is that FleetCrown could already answer every one of those + * questions EXACTLY — `getStuckGoals`, `getGoalsDueSoon`, `listUpcomingCommitments`, + * `getEventsDueSoon` and `getTodayHabits` have existed for months. They were + * simply never wired into the assistant. The model was asked to guess at + * something the database knew. + * + * So: these are SQL predicates with exact answers, not judgment calls. Computing + * them and handing the model a settled result strictly dominates injecting raw + * rows and hoping it filters correctly — a language model can only add error to + * a `WHERE progress = 0 AND updated_at < now() - 30d`. The model's remaining job + * is to PHRASE and PRIORITISE, which is genuinely what it is good at. + * + * An empty result is a real answer ("nothing is due") and is rendered as such — + * distinguishable from the query never having run, which is the distinction the + * old context could not express. + */ +import { getStuckGoals, getGoalsDueSoon, listUpcomingCommitments } from "@/db/queries/today"; +import { getEventsDueSoon } from "@/db/queries/events"; +import { getTodayHabits } from "@/db/queries/habits"; +import type { Directive } from "@/lib/agent/core/contract"; + +/** Days ahead treated as "imminent" for the day-planning brief. */ +const IMMINENT_DAYS = 3; +/** Progress-at-zero staleness window, matching getStuckGoals' own default. */ +const STUCK_DAYS = 30; + +function dateLabel(d: Date | string | null): string { + if (!d) return "no date"; + const date = typeof d === "string" ? new Date(d) : d; + return Number.isNaN(date.getTime()) ? "no date" : date.toISOString().slice(0, 10); +} + +/** + * Build the computed half of Loki's context. + * + * Every branch is best-effort: a failing query yields a directive whose answer + * is empty and whose method says it failed, rather than being dropped. A + * silently missing directive is indistinguishable from "nothing matched", and + * that ambiguity is what lets a model fill the gap — so failure is stated. + */ +export async function buildDailyBrief(userId: string): Promise { + const [stuck, goalsDue, commitments, events, habits] = await Promise.all([ + getStuckGoals(userId, STUCK_DAYS).catch(() => null), + getGoalsDueSoon(userId, IMMINENT_DAYS).catch(() => null), + listUpcomingCommitments(userId, IMMINENT_DAYS).catch(() => null), + getEventsDueSoon(userId, IMMINENT_DAYS).catch(() => null), + getTodayHabits(userId).catch(() => null), + ]); + + const directives: Directive[] = []; + + directives.push( + stuck === null + ? { question: `goals stuck at 0% for ${STUCK_DAYS}+ days`, answer: [], method: "QUERY FAILED — treat as unknown, not as none" } + : { + question: `goals stuck at 0% for ${STUCK_DAYS}+ days`, + method: `SQL: status=active AND progress=0 AND updated_at < now()-${STUCK_DAYS}d`, + answer: stuck.map((g) => `${g.title}${g.entityName ? ` (${g.entityName})` : ""} — untouched since ${dateLabel(g.updatedAt)}`), + }, + ); + + directives.push( + goalsDue === null + ? { question: `goals with a target date inside ${IMMINENT_DAYS} days`, answer: [], method: "QUERY FAILED — treat as unknown, not as none" } + : { + question: `goals with a target date inside ${IMMINENT_DAYS} days`, + method: `SQL: status=active AND target_date <= now()+${IMMINENT_DAYS}d`, + answer: goalsDue.map((g) => `${g.title} — due ${dateLabel(g.targetDate)}, ${g.progress ?? 0}% done`), + }, + ); + + directives.push( + commitments === null + ? { question: `commitments due inside ${IMMINENT_DAYS} days`, answer: [], method: "QUERY FAILED — treat as unknown, not as none" } + : { + question: `commitments due inside ${IMMINENT_DAYS} days`, + method: `SQL: status=active AND due_date <= now()+${IMMINENT_DAYS}d`, + answer: commitments.map((c) => `${c.description} — due ${dateLabel(c.dueDate)}`), + }, + ); + + directives.push( + events === null + ? { question: `events/deadlines inside ${IMMINENT_DAYS} days`, answer: [], method: "QUERY FAILED — treat as unknown, not as none" } + : { + question: `events/deadlines inside ${IMMINENT_DAYS} days`, + method: `SQL: deadline <= now()+${IMMINENT_DAYS}d`, + answer: events.map((e) => `${e.name} (${e.type}) — deadline ${dateLabel(e.deadline)}`), + }, + ); + + // "Most at risk of breaking today" has an exact reading: not yet done today, + // ordered by how much streak is on the line. Ties are broken by streak length + // because a longer streak is a larger loss — that is a product decision, and + // it belongs here in code where it is inspectable, not in a prompt where the + // model re-invents it differently every turn. + directives.push( + habits === null + ? { question: "habit most at risk today", answer: [], method: "QUERY FAILED — treat as unknown, not as none" } + : { + question: "habit most at risk today", + method: "not yet checked off today, ranked by streak at stake (longest first)", + answer: habits + .filter((h) => !h.doneToday) + .sort((a, b) => b.streak - a.streak) + .slice(0, 3) + .map((h) => `${h.title} — ${h.streak}-day streak at stake, not yet done today`), + }, + ); + + return directives; +} diff --git a/src/lib/agent/context.ts b/src/lib/agent/context.ts new file mode 100644 index 000000000..3a2c7ae84 --- /dev/null +++ b/src/lib/agent/context.ts @@ -0,0 +1,96 @@ +/** + * Loki's grounded context assembly — the replacement for the prose fleet-context + * block that produced the 2026-08-13 fabrications. + * + * What changed, and why each change is load-bearing: + * + * Before After + * ────────────────────────────────────── ──────────────────────────────────── + * Projects + RAG chunks only. Projects, PEOPLE, and RAG — people + * were never in context, which is why + * "who should I reach out to" was + * answered from an unrelated JSON blob + * in the OpenClaw agent's workspace. + * + * Prose blob per project. Typed records with declared fields; + * unstored fields render as an + * explicit ``. + * + * "cite the [source]" as advice. Enumerated citation ids, checked + * mechanically after generation. + * + * Goals/habits/commitments absent, but Computed by SQL and handed over as + * routinely asked about. settled results. + * + * Budgeting note: the fact set is capped, because a small model given forty + * records answers about the wrong one. Cheap deterministic facts (people, + * projects) are kept whole; retrieved documents are the elastic part. + */ +import { assignFactIds, renderFacts, type Fact } from "@/lib/agent/core/facts"; +import { buildGroundedContext, type Directive } from "@/lib/agent/core/contract"; +import { peopleFacts, projectFacts, documentFacts, economyFacts } from "@/lib/agent/sources"; +import { buildDailyBrief } from "@/lib/agent/brief"; + +/** Retrieved-document budget. Small models degrade past roughly this many. */ +const DOC_K = 6; +/** External (OrangeCat) items per kind — a supporting signal, not the subject. */ +const ECON_K = 3; + +/** + * Cues that the turn is a planning/triage question, which is when the computed + * brief is worth its tokens. Everything else skips it — a brief on every turn + * would push the records out of the window on small-context models. + * + * Matched loosely on purpose: a false positive costs tokens, a false negative + * costs a fabricated answer, and those are not symmetric. + */ +const PLANNING_CUES = + /\b(plan|today|day|urgent|priorit|attention|focus|stuck|due|deadline|overdue|next|habit|commit|risk|blocked|first)\b/i; + +export type GroundedTurn = { + /** The full block to prepend to the model's input. */ + context: string; + /** Facts with ids assigned — the caller needs these to verify the answer. */ + facts: Fact[]; + /** Computed answers, kept so the verifier can admit them as evidence. */ + directives: Directive[]; +}; + +/** + * Assemble everything Loki is allowed to know this turn. + * + * Best-effort by design: any source that throws contributes nothing rather than + * failing the turn. The contract adapts automatically — with fewer facts it + * permits fewer citations, and with none it instructs a refusal. + */ +export async function buildGroundedTurn(userId: string, message: string): Promise { + const wantsBrief = PLANNING_CUES.test(message); + + const [people, projects, docs, economy, directives] = await Promise.all([ + peopleFacts(userId, message).catch(() => [] as Fact[]), + projectFacts(userId).catch(() => [] as Fact[]), + documentFacts(userId, message, DOC_K).catch(() => [] as Fact[]), + economyFacts(message, ECON_K).catch(() => [] as Fact[]), + wantsBrief ? buildDailyBrief(userId).catch(() => [] as Directive[]) : Promise.resolve([] as Directive[]), + ]); + + // Order matters for attention: deterministic records first (they are simply + // true), then retrieved documents, then external signal (ranked guesses about + // relevance, from another system entirely). + const facts = assignFactIds([...projects, ...people, ...docs, ...economy]); + + return { + facts, + directives, + context: buildGroundedContext({ facts, directives, renderedFacts: renderFacts(facts) }), + }; +} + +/** + * Evidence strings the verifier may treat as legitimately known beyond the fact + * set — currently the computed brief, whose contents are true by construction + * but do not live in any Fact. + */ +export function directiveEvidence(directives: Directive[]): string[] { + return directives.flatMap((d) => [d.question, ...d.answer]); +} diff --git a/src/lib/agent/core/README.md b/src/lib/agent/core/README.md new file mode 100644 index 000000000..1689bd4dd --- /dev/null +++ b/src/lib/agent/core/README.md @@ -0,0 +1,74 @@ +# agent/core — the grounding harness + +Pure, dependency-free TypeScript shared by **Loki** (FleetCrown) and **Cat** +(OrangeCat). No DB, no network, no framework, no imports outside this directory. +That constraint is what makes it mirrorable, and it is enforced by the drift +check — do not relax it. + +## What it is for + +Both assistants had the same class of failure: a model asked to fill a rigid +answer format against thin context invents the missing parts, and the invention +is indistinguishable from the truth because both arrive as confident prose. + +The harness makes unsupported claims **hard to express** rather than merely +discouraged: + +| Module | Mechanism | +|---|---| +| `facts.ts` | Records with a **declared field set**. Fields with no stored value render as an explicit ``, so absence is a stated negative rather than silence. Each record gets a citation id. | +| `contract.ts` | A rules block **generated from this turn's facts** — enumerating the legal citation ids and the concrete gaps — plus `Directive`, for answers the app computed in SQL and the model may only phrase. | +| `verify.ts` | A deterministic post-generation check. Flags citations that resolve to nothing, and proper nouns / numbers / paths with no source in the records or the user's message. No extra model call. | + +## Why absence must be explicit + +Loki once reported a contact as *"Ilya Druzhnikov (UZH)"*. The stored record had +no organisation field, and the string `UZH` appears nowhere in the operator's +data — it is the substring inside dr**UZH**nikov, surfaced by a keyword match and +then narrated as an affiliation. + +A field the model was never shown is easy to invent. A field it was shown as +`affiliation: ` is a specific negative it has to actively +contradict. That is the whole design. + +## Why the verifier is deterministic + +It runs on **every** turn, including free-tier ones on small models — which is +exactly where fabrication is most likely. A verifier that costs a frontier call +is one that gets disabled where it matters most. + +It works because fabrication is overwhelmingly *nominal*: models invent +organisations, titles, file paths, phone numbers and dates. Those are +mechanically recognisable and, if genuine, must appear in the retrieved records. + +## Mirroring — read before editing + +This directory is **duplicated verbatim** in two repos: + +``` +fleetcrown/src/lib/agent/core/ ← canonical +orangecat/src/services/agent-core/ ← mirror +``` + +`scripts/test/agent-core-drift.ts` in **both** repos compares SHA-256 per file +and fails CI on any difference. So: + +1. Edit the FleetCrown copy. +2. Run `npm run sync:agent-core` (FleetCrown) to push the mirror. +3. Commit both repos. + +This duplication is deliberate and temporary. The two apps have incompatible +data layers (Drizzle/Postgres vs Supabase), so a shared package was not worth +blocking on — but two silently-diverging copies of "what counts as grounded" +would be worse than either. The drift check buys SSOT-in-practice now; the exit +is extraction to `@fleet/agent-core` (the `@fleet/ai-forms` pattern), after +which both repos import instead of mirroring. + +## What belongs here vs in the app + +**Here:** anything that defines what grounding *means*. +**In the app:** anything that knows where data lives — the adapters that map +rows to `Fact`s (`fleetcrown/src/lib/agent/sources.ts`, +`orangecat/src/services/cat/sources.ts`) and the SQL behind `Directive`s. + +If you find yourself importing a DB client here, the code belongs in an adapter. diff --git a/src/lib/agent/core/contract.ts b/src/lib/agent/core/contract.ts new file mode 100644 index 000000000..d031457c4 --- /dev/null +++ b/src/lib/agent/core/contract.ts @@ -0,0 +1,132 @@ +/** + * The grounding contract — the rules block that ships with every turn's facts. + * MIRRORED MODULE (see core/README.md). + * + * Why this is generated rather than a hand-written constant: a standing prose + * rule ("only use provided context") is a weak signal that models trade away + * under format pressure. The failure that motivated this harness was exactly + * that — a prompt demanding "1 focus, 3 tasks, 1 person, under 150 words, no + * hedging" got four confidently-formatted answers, three of them invented, + * against a context block that already said "if a question falls outside this + * context, say so rather than guessing". + * + * The lesson: the model did not disobey a rule it forgot. It obeyed the + * STRONGER of two conflicting instructions — fill five slots — because nothing + * made the empty slot expressible. So this block does three things a static + * prompt cannot: + * + * 1. Names the exact citation handles that exist this turn, so "cite a fact" + * is a closed-set choice rather than free text. + * 2. Names the exact fields that are unrecorded THIS TURN, so the prohibition + * is concrete ("you have no affiliation for any person here") instead of + * abstract. + * 3. Supplies the escape hatch verbatim, so refusing a slot is a cheaper + * token path than inventing one. + */ +import { NOT_RECORDED, unrecordedFields, type Fact } from "./facts"; + +/** The exact string the model must emit when a slot cannot be filled. */ +export const NO_BASIS = "Not in your data."; + +/** + * Build the contract for a specific fact set. Empty fact sets get the strictest + * form — with nothing retrieved, EVERY answer must be a refusal, and saying so + * plainly beats hoping the model notices the context block is empty. + */ +export function buildContract(facts: Fact[]): string { + const ids = facts.map((f) => `[${f.id}]`).join(" "); + const gaps = unrecordedFields(facts); + + const rules = [ + "## Grounding contract — this overrides every formatting instruction below", + "", + "You are answering from a fixed set of records. They are the ONLY things you know about the operator.", + "", + facts.length === 0 + ? `1. NO records were retrieved for this turn. You therefore cannot answer any question about the operator's projects, people, goals, habits, commitments or events. Reply "${NO_BASIS}" and say what you would need.` + : `1. Every claim about the operator MUST cite a record id. Legal citations this turn, and no others: ${ids}`, + `2. A field shown as \`${NOT_RECORDED}\` means you DO NOT KNOW it. Never supply a value for it — not from the record's own wording, not from a name that looks like a place or an organisation, not from general knowledge about a similarly-named person. A surname is not an employer.`, + `3. If any part of the request has no supporting record, answer that part with exactly "${NO_BASIS}" and continue with the parts you can support. A requested format NEVER obliges you to invent an item. Returning three of five requested items, each cited, is a correct and complete answer.`, + "4. Do not describe a person's role, employer, seniority, or history unless a record field states it. Do not infer an organisation from a name.", + "5. You have not browsed the web this turn. If asked to research someone, say you cannot and report only what the records hold.", + "6. If you are correcting an earlier answer, the correction is subject to every rule above — cite the record, or say the record does not exist.", + ]; + + if (gaps.length > 0) { + rules.push( + "", + `Unrecorded in THIS turn's records — you have no value for any of these and must not state one: ${gaps.join(", ")}`, + ); + } + + return rules.join("\n"); +} + +/** + * A deterministic answer computed by the app, not the model. + * + * Some questions are not judgment calls at all. "Which goals are stuck at 0% + * for 30+ days", "what is due in the next 3 days", "which habit is at risk" + * are SQL predicates with exact answers, and asking a language model to derive + * them from injected prose is strictly worse than computing them: it can only + * introduce error. The model's job is to PHRASE the result, not to derive it. + * + * `answer` is empty when the query ran and found nothing — which is itself a + * real, citable answer ("nothing is due"), and crucially different from the + * query never having run. + */ +export type Directive = { + /** What was asked, in the app's words: "goals stuck 30+ days". */ + question: string; + /** Computed result lines. Empty array = ran, found nothing. */ + answer: string[]; + /** How it was computed, shown to the model so it can be honest about method. */ + method: string; +}; + +/** + * Render computed answers. These are stated as settled, because they are: the + * model must not re-derive, second-guess, or "improve" them, and an empty + * result must be reported as an empty result rather than backfilled from the + * fact set. + */ +export function renderDirectives(directives: Directive[]): string { + if (directives.length === 0) return ""; + const blocks = directives.map((d) => { + const body = + d.answer.length > 0 + ? d.answer.map((a) => ` - ${a}`).join("\n") + : " (none — the query ran and matched nothing)"; + return ` ${d.question} [${d.method}]\n${body}`; + }); + return [ + "## Computed answers — already resolved, do not re-derive", + "These were computed directly from the database for this turn. They are exact.", + "Report them as given. Where the result is empty, say so plainly — do not substitute a plausible item from the records.", + "", + ...blocks, + ].join("\n"); +} + +/** + * Assemble the full grounded context: contract, computed answers, then records. + * + * Order is deliberate and load-bearing. The contract comes FIRST so it frames + * everything read afterwards, and the records come LAST so they sit closest to + * the user's question — the position small models weight most heavily. + */ +export function buildGroundedContext(input: { + facts: Fact[]; + directives?: Directive[]; + renderedFacts: string; +}): string { + return [ + buildContract(input.facts), + renderDirectives(input.directives ?? []), + input.facts.length > 0 + ? ["## Records", "", input.renderedFacts].join("\n") + : "## Records\n\n(none retrieved)", + ] + .filter(Boolean) + .join("\n\n---\n\n"); +} diff --git a/src/lib/agent/core/facts.ts b/src/lib/agent/core/facts.ts new file mode 100644 index 000000000..6960c51e9 --- /dev/null +++ b/src/lib/agent/core/facts.ts @@ -0,0 +1,165 @@ +/** + * Facts — the unit of grounded context. MIRRORED MODULE (see core/README.md). + * + * The problem this solves, concretely. Loki was asked who to contact and + * answered "Ilya Druzhnikov (UZH)". The stored record is: + * + * { displayName: "Ilya Druzhnikov", channels: { whatsapp: "+1650…" } } + * + * There is no org field, and the string "UZH" appears nowhere in the operator's + * data — it is the substring inside dr-UZH-nikov. A keyword match produced an + * affiliation out of a surname, and prose context gave the model no way to tell + * that "affiliation" was a field it had never been shown. + * + * The fix is representational, not a prompt instruction. A Fact is a RECORD with + * a DECLARED field set, and every declared field is rendered — including the ones + * with no value, which render as an explicit ``. A model that reads + * + * affiliation: + * + * is being told a specific negative, which is far harder to overwrite than the + * silence of a field that simply wasn't mentioned. Absence becomes evidence. + * + * Every fact also carries a short stable id ([F3]) so the answer can cite spans + * and `verify.ts` can check citations mechanically rather than by vibes. + * + * Pure: no DB, no network, no framework. Apps map their rows into Facts via + * their own adapters (FleetCrown: src/lib/agent/sources; OrangeCat: services/cat/sources). + */ + +/** A field that is declared for a record kind but has no stored value. */ +export const NOT_RECORDED = ""; + +/** + * One grounded record. `fields` must contain an entry for EVERY key in the + * kind's declared field list — `null` where nothing is stored. Builders should + * go through `makeFact`, which enforces that against the registry. + */ +export type Fact = { + /** Short citation handle, assigned by `assignFactIds` (F1, F2, …). */ + id: string; + /** Record kind — must be a key of the FACT_KINDS registry. */ + kind: string; + /** Human label for the record (a name, a title). Never invented. */ + subject: string; + /** Declared field → stored value, or null for "nothing stored". */ + fields: Record; + /** Where this came from, shown to the model: "people table", "goals table". */ + source: string; + /** + * Relevance score when the fact came from similarity search. Absent for facts + * fetched deterministically (a SQL filter) — those are not ranked, they are + * simply true, and the distinction matters to the reader. + */ + similarity?: number; +}; + +/** + * The declared field set per record kind — the SSOT for "what could be known + * about this kind of thing". Adding a field here makes it render as + * `` everywhere it is missing, which is the entire anti-invention + * mechanism: the model can only ever see fields we chose to declare. + * + * Deliberately includes fields we do NOT store (a person's `affiliation`, + * `role`, `employer`). That is not an oversight — those are exactly the + * attributes models invent, so naming them and marking them unrecorded is the + * point. Do not "clean up" this list by deleting the empty ones. + */ +export const FACT_KINDS: Record = { + person: ["name", "affiliation", "role", "how_we_met", "last_interaction", "notes", "channels"], + project: ["name", "status", "stack", "description", "latest_dev_log", "repo"], + goal: ["title", "project", "progress", "target_date", "last_updated"], + habit: ["title", "frequency", "current_streak", "last_checked"], + commitment: ["title", "due", "counterparty", "status"], + event: ["name", "type", "deadline", "url", "status"], + document: ["title", "source", "excerpt"], +}; + +/** Field list for a kind; unknown kinds fall back to whatever the fact carries. */ +export function declaredFields(kind: string, fallback: string[] = []): readonly string[] { + return FACT_KINDS[kind] ?? fallback; +} + +/** + * Build a Fact with every declared field present. Values not supplied become + * null (→ ``). Undeclared keys are DROPPED rather than passed + * through: if a field is worth showing the model it is worth declaring in + * FACT_KINDS, otherwise the registry stops describing what the model sees. + */ +export function makeFact(input: { + kind: string; + subject: string; + source: string; + values?: Record; + similarity?: number; +}): Fact { + const keys = declaredFields(input.kind, Object.keys(input.values ?? {})); + const fields: Record = {}; + for (const key of keys) { + const raw = input.values?.[key]; + const trimmed = typeof raw === "string" ? raw.trim() : raw; + fields[key] = trimmed ? String(trimmed) : null; + } + return { + id: "", + kind: input.kind, + subject: input.subject, + source: input.source, + fields, + ...(input.similarity !== undefined ? { similarity: input.similarity } : {}), + }; +} + +/** Stamp sequential citation ids. Call once, after assembling the final set. */ +export function assignFactIds(facts: Fact[]): Fact[] { + return facts.map((f, i) => ({ ...f, id: `F${i + 1}` })); +} + +/** Every citation handle in a fact set — the only legal citations in an answer. */ +export function factIds(facts: Fact[]): Set { + return new Set(facts.map((f) => f.id)); +} + +/** + * Render facts for the model. One block per record, every declared field on its + * own line, unrecorded fields stated explicitly. + * + * [F3] person — Elena Weber SINGA Switzerland (people table) + * name: Elena Weber SINGA Switzerland + * affiliation: + * role: + * channels: whatsapp +41774730093 + * + * The line-per-field shape matters for small models: a flat prose blob invites + * summarising (and summarising is where invention creeps in), whereas a field + * list invites lookup. Observed with 8B models — the same prompt over a blob + * hallucinates roles, over a field list it reports ``. + */ +export function renderFacts(facts: Fact[]): string { + if (facts.length === 0) return ""; + return facts + .map((f) => { + const head = `[${f.id}] ${f.kind} — ${f.subject} (${f.source})`; + const body = Object.entries(f.fields).map( + ([k, v]) => ` ${k}: ${v ?? NOT_RECORDED}`, + ); + return [head, ...body].join("\n"); + }) + .join("\n\n"); +} + +/** + * Which declared fields are unrecorded across the set, as + * `kind.field` keys. The contract block names these explicitly so the rule + * "do not state an affiliation" is anchored to a concrete gap in THIS turn's + * context rather than being a standing abstraction the model may ignore. + */ +export function unrecordedFields(facts: Fact[]): string[] { + const gaps = new Set(); + for (const f of facts) { + for (const [k, v] of Object.entries(f.fields)) { + if (v === null) gaps.add(`${f.kind}.${k}`); + } + } + return [...gaps].sort(); +} diff --git a/src/lib/agent/core/verify.ts b/src/lib/agent/core/verify.ts new file mode 100644 index 000000000..25103f699 --- /dev/null +++ b/src/lib/agent/core/verify.ts @@ -0,0 +1,279 @@ +/** + * Groundedness verifier — MIRRORED MODULE (see core/README.md). + * + * Runs on the generated answer and reports claims the fact set does not support. + * Deliberately deterministic: no second model call, no embedding round-trip, no + * added cost or latency. That is a requirement, not a shortcut — this must run + * on every turn including the free-tier ones, and a verifier that costs a + * frontier call is one that gets disabled exactly where it is needed most. + * + * The insight that makes a cheap check work: fabrication is overwhelmingly + * NOMINAL. Models invent organisations, titles, people, file paths, phone + * numbers and dates — tokens that are mechanically recognisable and that must, + * if genuine, have appeared in the retrieved records or in what the user said. + * Grammar and hedging are hard to check; proper nouns and digits are easy. + * + * Scored against the real failure this was built from, every fabricated claim + * is caught by the proper-noun or numeric rule: + * + * "Ilya Druzhnikov (UZH)" → UZH: novel acronym + * "Accelerator & Bridge Program Manager" → novel proper-noun run + * "University of Liechtenstein", "START Summit" → novel proper-noun runs + * "/opt/fleetcrown/runner/.env" → novel path + * + * while the true parts ("Elena Weber SINGA Switzerland", "+41774730093") appear + * verbatim in the records and pass clean. + */ +import { NOT_RECORDED, type Fact } from "./facts"; + +export type Violation = { + kind: "unknown-citation" | "novel-proper-noun" | "novel-number" | "novel-path" | "uncited-claim"; + /** The offending text. */ + text: string; + /** Why it is a problem, phrased for a repair prompt the model will read. */ + detail: string; +}; + +export type VerifyResult = { + ok: boolean; + violations: Violation[]; +}; + +/** + * Words that are capitalised for reasons other than being a proper noun, or + * that are part of this system's own vocabulary. Kept deliberately small — + * every entry is a hole in the check, so add only what demonstrably causes + * false positives, never to silence a true one. + */ +const COMMON = new Set( + [ + // Sentence/structural + "the", "a", "an", "and", "or", "but", "if", "then", "so", "because", "not", + "this", "that", "these", "those", "it", "its", "your", "you", "i", "we", + "there", "here", "what", "which", "who", "when", "where", "why", "how", + "no", "yes", "none", "nothing", "today", "tomorrow", "yesterday", "now", + "next", "last", "first", "one", "two", "three", "primary", "focus", "task", + "tasks", "outreach", "note", "notes", "summary", "status", "update", + // Days / months — real words, never evidence of a fabricated entity + "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", + "january", "february", "march", "april", "may", "june", "july", "august", + "september", "october", "november", "december", + // This system's own nouns + "loki", "cat", "fleetcrown", "orangecat", "not", "recorded", + ].map((w) => w.toLowerCase()), +); + +/** Normalise for containment tests: casefold, collapse punctuation and space. */ +function norm(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9+]+/g, " ").replace(/\s+/g, " ").trim(); +} + +/** + * Everything the model was legitimately given this turn: record values, record + * subjects, and the user's own message (a name the user typed is fair to + * repeat). This is the corpus a claim must be traceable to. + */ +function buildEvidence(facts: Fact[], userMessage: string, extra: string[]): string { + const parts: string[] = [userMessage, ...extra]; + for (const f of facts) { + parts.push(f.subject, f.kind, f.source); + for (const v of Object.values(f.fields)) if (v) parts.push(v); + } + return norm(parts.join(" ")); +} + +/** + * Lowercase words that legitimately sit INSIDE a proper name and must not break + * it up: "University of Zurich", "Bank für Handel", "Institute for the Study of + * Complexity". Without these, the run splits at the connector and the check + * only ever sees the harmless halves ("University", "Zurich") while the actual + * fabricated entity slips through unnamed. + */ +const NAME_CONNECTORS = new Set(["of", "the", "for", "and", "de", "der", "des", "van", "von", "du", "da", "di", "für", "el", "al"]); + +/** + * Named-entity candidates: ALL-CAPS acronyms, capitalised words, and the + * multi-word runs they form (connectors allowed strictly between two + * capitalised tokens, never at an edge). + * + * Both the run AND its individual tokens are emitted, deliberately. The run + * catches composite inventions ("University of Zurich") that no single token + * reveals; the individual tokens catch an invented acronym sitting next to a + * real name ("Druzhnikov UZH"), where reporting only the run would name the + * real person in the violation and produce a repair prompt that deletes the + * true claim along with the false one. + * + * Sentence-initial single words are skipped — otherwise "Rotate the key" flags + * "Rotate". That costs a little recall at sentence starts and removes the + * dominant source of false positives; a fabricated name at a sentence start is + * still caught by its remaining tokens. + */ +function properNounRuns(text: string): string[] { + const out: string[] = []; + // Strip fenced and inline code — quoted identifiers are usually the user's + // own or a literal under discussion, not a claim about the world. + const prose = text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " "); + + for (const sentence of prose.split(/(?<=[.!?:\n])\s+/)) { + const tokens = sentence.match(/[A-Za-z][A-Za-z0-9&.'’-]*/g) ?? []; + let run: string[] = []; + + const flush = () => { + // Trim trailing connectors so "University of" never stands as a run. + while (run.length > 0 && NAME_CONNECTORS.has(run[run.length - 1].toLowerCase())) run.pop(); + if (run.length > 1) out.push(run.join(" ")); + run = []; + }; + + tokens.forEach((tok, i) => { + const bare = tok.replace(/[.'’-]+$/, ""); + const isAcronym = /^[A-Z]{2,}$/.test(bare); + const isCapitalised = /^[A-Z][a-z]/.test(bare); + const isConnector = NAME_CONNECTORS.has(bare.toLowerCase()); + + if (isAcronym || (isCapitalised && i > 0)) { + run.push(bare); + out.push(bare); // individually checkable + return; + } + // A connector only continues a run that has already started. + if (isConnector && run.length > 0) { + run.push(bare); + return; + } + flush(); + }); + flush(); + } + return out; +} + +/** Digit groups worth checking: phone numbers, years, percentages, counts ≥ 2 digits. */ +function numericClaims(text: string): string[] { + const prose = text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " "); + return (prose.match(/\+?\d[\d\s().-]{3,}\d|\b\d{2,}%?\b/g) ?? []).map((s) => s.trim()); +} + +/** + * File and path references — a favourite fabrication, and an unusually + * damaging one because naming a file implies the model READ it. + * + * Covers absolute paths (`/opt/fleetcrown/runner/.env`), relative paths + * (`data/contact-resolver.json`), and bare filenames with a data/config + * extension. The relative form matters: when challenged on the UZH claim, the + * model "corrected" itself by asserting what `data/contact-resolver.json` + * contained — a file it was never given. That reads as citing a source, which + * is precisely why an unverified correction is more corrosive than the + * original error: it spends the credibility the user was trying to restore. + */ +function pathClaims(text: string): string[] { + const patterns = [ + /(?:^|[\s("'`])(\/[A-Za-z0-9_.\-/]{4,})/g, // absolute + /(?:^|[\s("'`])([A-Za-z0-9_.-]+\/[A-Za-z0-9_.\-/]*[A-Za-z0-9_-]\.[a-z]{2,5})/g, // relative w/ extension + /(?:^|[\s("'`])([A-Za-z0-9_-]+\.(?:json|env|ya?ml|sql|toml|ini|conf|log))\b/g, // bare config filename + ]; + const out = new Set(); + for (const re of patterns) { + for (const m of text.matchAll(re)) if (m[1]) out.add(m[1]); + } + return [...out]; +} + +/** + * Verify an answer against the facts it was supposed to come from. + * + * `extraEvidence` lets a caller admit sources outside the fact set — computed + * directive output, a tool result the model legitimately saw this turn. + * Anything not in facts, the user's message, or extraEvidence is unsupported + * by construction. + */ +export function verifyAnswer(input: { + answer: string; + facts: Fact[]; + userMessage: string; + extraEvidence?: string[]; +}): VerifyResult { + const { answer, facts, userMessage } = input; + const evidence = buildEvidence(facts, userMessage, input.extraEvidence ?? []); + const legalIds = new Set(facts.map((f) => f.id.toUpperCase())); + const violations: Violation[] = []; + + // 1. Citations must resolve. A citation to a record that does not exist is + // the strongest possible signal of fabrication — it invents its own proof. + for (const cite of answer.match(/\[F\d+\]/g) ?? []) { + const id = cite.slice(1, -1).toUpperCase(); + if (!legalIds.has(id)) { + violations.push({ + kind: "unknown-citation", + text: cite, + detail: `${cite} is not a record in this turn's context. Cite only ids that were provided, or say there is no record.`, + }); + } + } + + // 2. Named entities must be traceable. This is the anti-"UZH" rule. + const seen = new Set(); + for (const run of properNounRuns(answer)) { + const n = norm(run); + if (!n || seen.has(n)) continue; + seen.add(n); + // Single common words are noise; multi-word runs always checked. + const words = n.split(" "); + if (words.length === 1 && (COMMON.has(words[0]) || words[0].length < 2)) continue; + if (words.every((w) => COMMON.has(w))) continue; + if (evidence.includes(n)) continue; + // A multi-word run whose every word is individually attested is fine — + // it is a rephrasing, not a new entity. + if (words.length > 1 && words.every((w) => COMMON.has(w) || evidence.includes(w))) continue; + violations.push({ + kind: "novel-proper-noun", + text: run, + detail: `"${run}" does not appear in any record or in the operator's message. If it is an organisation, role, or place you associated with someone, the relevant field is ${NOT_RECORDED} — remove the claim.`, + }); + } + + // 3. Numbers must be traceable — invented phone numbers and dates read as + // authoritative precisely because they are specific. + for (const num of numericClaims(answer)) { + const n = norm(num); + if (!n || n.length < 2) continue; + if (evidence.includes(n)) continue; + // Compare digits-only too: "+41 77 473 00 93" vs stored "+41774730093". + const digits = num.replace(/\D/g, ""); + if (digits.length >= 4 && evidence.replace(/\D/g, "").includes(digits)) continue; + if (digits.length < 4) continue; // small counts ("3 tasks") are rhetorical + violations.push({ + kind: "novel-number", + text: num, + detail: `The number "${num}" is not in any record. Do not state contact details, dates, or metrics that were not provided.`, + }); + } + + // 4. Paths — "update the key in /opt/fleetcrown/runner/.env" was invented + // wholesale, and its specificity is what made it convincing. + for (const p of pathClaims(answer)) { + if (evidence.includes(norm(p))) continue; + violations.push({ + kind: "novel-path", + text: p, + detail: `The path "${p}" is not in any record. Do not state file locations you were not given.`, + }); + } + + return { ok: violations.length === 0, violations }; +} + +/** + * Turn violations into a repair instruction. One cheap retry with this appended + * fixes most turns, because the model is not being asked to know more — only to + * delete claims it cannot support. + */ +export function buildRepairPrompt(violations: Violation[], noBasisPhrase: string): string { + return [ + "Your previous answer contained claims not supported by the records. Rewrite it.", + "", + ...violations.map((v) => `- ${v.detail}`), + "", + `Remove every unsupported claim. Where removing one empties a requested item, write "${noBasisPhrase}" for that item instead of substituting something else. Keep everything that was supported, unchanged.`, + ].join("\n"); +} diff --git a/src/lib/agent/sources.ts b/src/lib/agent/sources.ts new file mode 100644 index 000000000..3b4cd81f4 --- /dev/null +++ b/src/lib/agent/sources.ts @@ -0,0 +1,201 @@ +/** + * FleetCrown → Fact adapters. The app-bound half of the harness: everything + * here knows about Drizzle and FleetCrown's schema; nothing in core/ does. + * + * The rule these encode: a Fact may only carry values that were STORED. Nothing + * here derives, guesses, or enriches. Where FleetCrown has no value for a + * declared field, the field stays null and renders as `` — which + * is the whole mechanism, so resist the urge to be helpful by inferring an + * affiliation from a name, a role from a description, or a status from silence. + */ +import { fetchOpenDemand, searchEconomy } from "@/lib/integrations/orangecat-demand"; +import { searchPeople, type PersonWithAttributes } from "@/db/queries/people"; +import { getUserProjects } from "@/db/queries/user-projects"; +import { searchKnowledge, type KnowledgeHit } from "@/db/queries/knowledge-embeddings"; +import { embeddingsEnabled } from "@/lib/rag/embeddings"; +import { cleanDescription } from "@/lib/project-display"; +import { makeFact, type Fact } from "@/lib/agent/core/facts"; +import type { DevLogEntry, UserProject } from "@/db/schema/user-projects"; + +const PEOPLE_LIMIT = 12; +const PROJECT_LIMIT = 40; +const DOC_CHUNK_MAX = 400; + +/** + * Attribute keys that map onto a declared `person` field. Anything not listed + * is folded into `notes` rather than dropped, so a stored fact is never + * invisible — but it also never silently becomes an "affiliation". + * + * `affiliation` and `role` are present here so that IF the operator ever + * records them they are used. Today they almost never are, which is exactly + * why the model kept inventing them. + */ +const PERSON_ATTR_MAP: Record = { + affiliation: "affiliation", + org: "affiliation", + organisation: "affiliation", + organization: "affiliation", + company: "affiliation", + employer: "affiliation", + role: "role", + title: "role", + job: "role", + met: "how_we_met", + how_we_met: "how_we_met", + context: "how_we_met", +}; + +/** Channel-ish attribute keys, collapsed into the single `channels` field. */ +const CHANNEL_KEYS = new Set(["phone", "whatsapp", "telegram", "email", "signal", "mobile"]); + +function personToFact(p: PersonWithAttributes): Fact { + const values: Record = { + name: p.name, + affiliation: null, + role: null, + how_we_met: null, + last_interaction: p.lastInteraction ? new Date(p.lastInteraction).toISOString().slice(0, 10) : null, + notes: p.description, + channels: null, + }; + + const channels: string[] = []; + const leftovers: string[] = []; + + for (const [rawKey, rawVal] of Object.entries(p.attrs ?? {})) { + const key = rawKey.toLowerCase().trim(); + const val = String(rawVal ?? "").trim(); + if (!val) continue; + if (CHANNEL_KEYS.has(key)) { + channels.push(`${key} ${val}`); + continue; + } + const mapped = PERSON_ATTR_MAP[key]; + if (mapped) { + values[mapped] = val; + continue; + } + leftovers.push(`${rawKey}: ${val}`); + } + + if (channels.length > 0) values.channels = channels.join(", "); + if (leftovers.length > 0) { + values.notes = [values.notes, leftovers.join("; ")].filter(Boolean).join(" · "); + } + + return makeFact({ kind: "person", subject: p.name, source: "people table", values }); +} + +/** + * People facts for a turn. + * + * Retrieval is by name substring (`searchPeople`) when the query names someone, + * and otherwise by recency of interaction. Substring matching is acceptable + * HERE, on the `name` column, because the match is used only to SELECT a + * record — every field the model then sees is the record's own stored value. + * The original bug was not substring search per se; it was substring search + * over an untyped blob whose incidental matches (`dr-UZH-nikov`) were then + * narrated as attributes. + */ +export async function peopleFacts(userId: string, query: string): Promise { + const named = await searchPeople(userId, query.trim().slice(0, 80), PEOPLE_LIMIT).catch(() => null); + const rows = named?.people ?? []; + if (rows.length > 0) return rows.map(personToFact); + const recent = await searchPeople(userId, "", PEOPLE_LIMIT).catch(() => null); + return (recent?.people ?? []).map(personToFact); +} + +function latestDevLog(project: UserProject): string | null { + const log = (project.devLog as DevLogEntry[]) ?? []; + const last = log[log.length - 1]; + if (!last?.done) return null; + return `${last.date ? `${last.date}: ` : ""}${last.done}`.replace(/\s+/g, " ").trim(); +} + +/** Every registered project — breadth, so "across all my projects" is answerable. */ +export async function projectFacts(userId: string): Promise { + const projects = await getUserProjects(userId).catch(() => [] as UserProject[]); + return projects.slice(0, PROJECT_LIMIT).map((p) => + makeFact({ + kind: "project", + subject: p.name, + source: "projects table", + values: { + name: p.name, + // There is no free-text status column — `is_active` is the only stored + // signal, so that is exactly what the model is told. Anything richer + // ("blocked", "shipping") would be a value nobody entered. + status: p.isActive ? "active" : "inactive", + stack: p.stack, + description: cleanDescription(p.description), + latest_dev_log: latestDevLog(p), + repo: p.gitUrl, + }, + }), + ); +} + +/** + * Semantic depth from the pgvector knowledge index. + * + * Note these come back as `document` facts carrying a similarity score, NOT as + * facts about the entity they mention. A retrieved dev-log chunk is evidence + * that a sentence was written, which is a weaker claim than the sentence being + * currently true — and collapsing that distinction is how stale notes get + * reported as present state. + */ +export async function documentFacts(userId: string, query: string, k: number): Promise { + if (!embeddingsEnabled() || !query.trim()) return []; + const hits: KnowledgeHit[] = await searchKnowledge(userId, query, { k }).catch(() => []); + return hits.map((h) => { + const project = (h.metadata?.project as string | undefined) ?? ""; + const title = (h.metadata?.title as string | undefined) ?? ""; + return makeFact({ + kind: "document", + subject: title || project || h.sourceId, + source: `${h.sourceType}${project ? ` · ${project}` : ""} (retrieved, similarity ${h.similarity.toFixed(2)})`, + similarity: h.similarity, + values: { + title: title || project || h.sourceId, + source: h.sourceType, + excerpt: h.chunk.replace(/\s+/g, " ").slice(0, DOC_CHUNK_MAX), + }, + }); + }); +} + +/** + * Live economy signal from OrangeCat — open needs and matching listings. This + * is what the fleet could BUILD FOR, and it is the one source here that is not + * the operator's own data, so its facts are labelled with their external origin + * ("orangecat.ch") to keep that distinction visible in the answer. + * + * Best-effort: a slow or down OrangeCat contributes no facts, never a failed turn. + */ +export async function economyFacts(query: string, limit: number): Promise { + const [demand, matches] = await Promise.all([ + fetchOpenDemand().catch(() => null), + query.trim() ? searchEconomy(query).catch(() => []) : Promise.resolve([]), + ]); + + const needFacts = (demand?.needs ?? []).slice(0, limit).map((n) => + makeFact({ + kind: "document", + subject: n.title, + source: "orangecat.ch · open demand", + values: { title: n.title, source: "orangecat open demand", excerpt: n.text.replace(/\s+/g, " ").slice(0, DOC_CHUNK_MAX) }, + }), + ); + + const matchFacts = matches.slice(0, limit).map((m) => + makeFact({ + kind: "document", + subject: m.title, + source: `orangecat.ch · ${m.type}`, + ...(m.similarity !== undefined ? { similarity: m.similarity } : {}), + values: { title: m.title, source: `orangecat ${m.type}`, excerpt: m.description.replace(/\s+/g, " ").slice(0, DOC_CHUNK_MAX) }, + }), + ); + + return [...needFacts, ...matchFacts]; +} diff --git a/src/lib/loki-core.ts b/src/lib/loki-core.ts index 8c69336a5..b232e972f 100644 --- a/src/lib/loki-core.ts +++ b/src/lib/loki-core.ts @@ -12,7 +12,10 @@ import { askGatewayAgent, isGatewayConfigured } from "@/lib/openclaw-gateway"; import { callGroqText, GROQ_FAST_MODEL } from "@/lib/groq"; import { getUserPreferences } from "@/db/queries/user-preferences"; -import { buildLokiFleetContext } from "@/lib/loki-fleet-context"; +import { buildGroundedTurn, directiveEvidence } from "@/lib/agent/context"; +import { verifyAnswer, buildRepairPrompt, type Violation } from "@/lib/agent/core/verify"; +import { NO_BASIS } from "@/lib/agent/core/contract"; +import type { Fact } from "@/lib/agent/core/facts"; import { APP_NAME } from "@/config/brand"; import { HTTP_TIMEOUT_LONG_MS } from "@/lib/constants/time"; @@ -79,29 +82,85 @@ async function callGroq(message: string, voice: string | null): Promise<{ text: export type AskLokiResult = { status: number; body: Record }; +/** + * Per-turn grounding report, returned to the client alongside the answer. + * + * Surfaced rather than swallowed on purpose. When a claim survives the repair + * pass the operator has to be able to SEE that this turn is suspect — the + * failure this harness exists to prevent was not that Loki was wrong, it was + * that being wrong looked exactly like being right. A visible flag restores the + * distinction even when the model cannot. + * + * `checked: false` means the turn had no records to check against (e.g. an + * anonymous caller), which is honestly different from "checked and clean". + */ +function groundingMeta(factCount: number, violations: Violation[]) { + return { + checked: factCount > 0, + ok: violations.length === 0, + factCount, + unsupported: violations.map((v) => ({ kind: v.kind, text: v.text })), + }; +} + /** * Ask Loki a question — the real OpenClaw agent via the gateway, with Groq as a * labelled degraded fallback. `sessionKey` lets callers keep a per-conversation * web thread; all web sessions share the same agent (`main`) and its memory. */ export async function askLoki(message: string, opts?: { sessionKey?: string; userId?: string }): Promise { - // Resolve the caller's writing-voice preference + fleet context once. The - // fleet context (all projects + RAG detail) is what makes Loki "on top of" - // the operator's work rather than a generic chat — see loki-fleet-context. + // Resolve the caller's writing-voice preference + the grounded turn once. + // The grounded turn (typed records + computed answers + the contract) is what + // makes Loki "on top of" the operator's work rather than a generic chat. // Both are best-effort: a slow/failed lookup degrades to plain Loki, never a // broken turn. - const [voice, fleetContext] = await Promise.all([ + const [voice, grounded] = await Promise.all([ opts?.userId ? getUserPreferences(opts.userId).then((p) => p.writingVoice).catch(() => null) : Promise.resolve(null), - opts?.userId ? buildLokiFleetContext(opts.userId, message).catch(() => "") : Promise.resolve(""), + opts?.userId + ? buildGroundedTurn(opts.userId, message).catch(() => null) + : Promise.resolve(null), ]); - // The message the brain actually sees: capability ground-truth + fleet context - // (both read-only background) ahead of the operator's question. Used by the - // gateway AND Groq paths so Loki answers with project knowledge and, critically, - // never over-claims what it can do — regardless of which one serves the turn. - const background = fleetContext ? `${LOKI_CAPABILITIES}\n\n---\n\n${fleetContext}` : LOKI_CAPABILITIES; + const facts: Fact[] = grounded?.facts ?? []; + const evidence = grounded ? directiveEvidence(grounded.directives) : []; + + // The message the brain actually sees: capability ground-truth + the grounded + // context (both read-only background) ahead of the operator's question. Used + // by the gateway AND Groq paths so Loki answers from records and, critically, + // never over-claims — regardless of which one serves the turn. + const background = grounded?.context ? `${LOKI_CAPABILITIES}\n\n---\n\n${grounded.context}` : LOKI_CAPABILITIES; const contextualMessage = `${background}\n\n---\n\n${message}`; + /** + * Check an answer and, if it makes unsupported claims, give the model exactly + * one chance to delete them. + * + * One retry, not a loop: the repair asks the model to REMOVE claims, not to + * find better ones, so a model that fails twice is not going to succeed on a + * third pass — it is going to burn the operator's latency. What survives a + * failed repair is returned WITH its violations attached rather than + * suppressed, because a wrong answer the operator can see is flagged is + * strictly safer than a wrong answer that looks clean. + */ + async function groundOrRepair( + text: string, + regenerate: (repair: string) => Promise, + ): Promise<{ text: string; violations: Violation[] }> { + if (facts.length === 0) return { text, violations: [] }; + const first = verifyAnswer({ answer: text, facts, userMessage: message, extraEvidence: evidence }); + if (first.ok) return { text, violations: [] }; + + console.warn( + "[loki] ungrounded claims, repairing:", + first.violations.map((v) => `${v.kind}:${v.text}`).join(", ").slice(0, 300), + ); + const repaired = (await regenerate(buildRepairPrompt(first.violations, NO_BASIS)).catch(() => "")).trim(); + if (!repaired) return { text, violations: first.violations }; + + const second = verifyAnswer({ answer: repaired, facts, userMessage: message, extraEvidence: evidence }); + return { text: repaired, violations: second.violations }; + } + // Real Loki: the OpenClaw agent (same brain + memory as Telegram). The voice // rides in as a one-line preface so the shared `main` agent honours it per-turn // without mutating its own persistent personality. @@ -117,9 +176,22 @@ export async function askLoki(message: string, opts?: { sessionKey?: string; use // answer as Loki's. Treat it as a failure and fall back — so Loki stays // useful even when the model isn't. if (res.ok && !isUnusableGatewayText(text) && !looksLikeFleetEcho(text)) { + // Ground the answer before it reaches the operator. The repair re-asks + // the SAME agent on the SAME session, so its own prior turn is in scope + // and it is editing rather than starting over. + const checked = await groundOrRepair(text, async (repair) => { + const again = await askGatewayAgent(repair, { sessionKey: opts?.sessionKey }); + return again.ok ? (again.text ?? "") : ""; + }); return { status: 200, - body: { ok: true, text, model: res.model ?? "openclaw/main", durationMs: res.durationMs ?? 0 }, + body: { + ok: true, + text: checked.text, + model: res.model ?? "openclaw/main", + durationMs: res.durationMs ?? 0, + grounding: groundingMeta(facts.length, checked.violations), + }, }; } const reason = !res.ok @@ -135,10 +207,31 @@ export async function askLoki(message: string, opts?: { sessionKey?: string; use } // Groq fallback — DEGRADED, labelled `via: "groq-fallback"` (not the real Loki brain). - // Still gets the fleet context so a degraded Loki is at least project-aware. + // Still gets the grounded context, and is still verified: the fallback path is + // a SMALLER model, so it is the path most likely to fabricate and the last one + // that should skip the check. try { const { text, model } = await callGroq(contextualMessage, voice); - return { status: 200, body: { ok: true, text, model, durationMs: 0, via: "groq-fallback" } }; + const checked = await groundOrRepair(text, async (repair) => { + // Groq is stateless here, so the repair must carry the answer being + // repaired — there is no session for it to refer back to. + const { text: fixed } = await callGroq( + `${contextualMessage}\n\n---\n\nYour previous answer:\n${text}\n\n---\n\n${repair}`, + voice, + ); + return fixed; + }); + return { + status: 200, + body: { + ok: true, + text: checked.text, + model, + durationMs: 0, + via: "groq-fallback", + grounding: groundingMeta(facts.length, checked.violations), + }, + }; } catch (e) { // Surface the actual Groq cause so the user can act (rotate key / wait out // the rate limit) instead of a generic "unavailable" wall. diff --git a/src/lib/loki-fleet-context.ts b/src/lib/loki-fleet-context.ts deleted file mode 100644 index 581cc28e7..000000000 --- a/src/lib/loki-fleet-context.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Loki's fleet awareness — the context that makes Loki "on top of all projects". - * - * Two layers, prepended to every Loki turn (see loki-core.askLoki): - * 1. Fleet index (BREADTH) — every active project, one compact line each with - * its latest dev-log state. Always present, so Loki can answer "across all - * my projects" comprehensively, not just whatever RAG happened to retrieve. - * 2. Retrieved detail (DEPTH) — top-K semantically-relevant chunks from the - * fleet-knowledge vector index (pgvector; project dossiers + dev logs), - * scoped to the query. This is the RAG half — deep, current, on-topic. - * - * Unlike the dispatch path (retrieveFleetContextBlock excludes the target - * project), Loki wants EVERY project — it is the captain's single point of view - * over the whole fleet. Returns "" when there's nothing to say (no projects and - * RAG off/empty), so callers can concat safely. - */ -import { getUserProjects } from "@/db/queries/user-projects"; -import { searchKnowledge, type KnowledgeHit } from "@/db/queries/knowledge-embeddings"; -import { embeddingsEnabled } from "@/lib/rag/embeddings"; -import { buildFleetIndex } from "@/lib/loki-fleet-index"; -import { fetchOpenDemand, buildDemandBlock, searchEconomy, buildEconomySearchBlock } from "@/lib/integrations/orangecat-demand"; -import type { UserProject } from "@/db/schema/user-projects"; - -const RAG_K = 6; -const RAG_POOL = 16; -const PER_SOURCE_CAP = 2; -const RAG_CHUNK_MAX = 300; - -/** The key a hit belongs to for diversity — its project, or essay slug. */ -function diversityKey(h: KnowledgeHit): string { - return (h.metadata?.project as string | undefined) || (h.metadata?.slug as string | undefined) || h.sourceId; -} - -/** - * Spread retrieval across projects/essays: from a larger similarity-ranked pool, - * take the top hits but cap how many come from any single source. Stops a - * cross-project question ("how do these fit together") from returning six chunks - * of one project and nothing of the others. - */ -function diversify(pool: KnowledgeHit[]): KnowledgeHit[] { - const seen = new Map(); - const picked: KnowledgeHit[] = []; - for (const h of pool) { - const key = diversityKey(h); - const n = seen.get(key) ?? 0; - if (n >= PER_SOURCE_CAP) continue; - seen.set(key, n + 1); - picked.push(h); - if (picked.length >= RAG_K) break; - } - return picked; -} - -/** RAG depth: diverse top-K relevant chunks across ALL projects for the query. */ -async function buildRetrievedDetail(userId: string, query: string): Promise { - if (!embeddingsEnabled() || !query.trim()) return ""; - const pool = await searchKnowledge(userId, query, { k: RAG_POOL }).catch(() => []); - if (pool.length === 0) return ""; - const hits = diversify(pool); - // Source-aware labels so Loki cites where each fact came from — a companion - // that shows its work. Kind + project (or essay title) precede the chunk. - const lines = hits.map((h) => { - const project = (h.metadata?.project as string | undefined) ?? ""; - const title = (h.metadata?.title as string | undefined) ?? ""; - let label: string; - switch (h.sourceType) { - case "thought": label = `essay: ${title || h.sourceId}`; break; - case "goal": label = `${project || "goal"} · goal${title ? `: ${title}` : ""}`; break; - case "dev_log": label = `${project} · dev log`; break; - default: label = project || h.sourceId; // project_profile / dossier - } - return `- [${label}] ${h.chunk.replace(/\s+/g, " ").slice(0, RAG_CHUNK_MAX)}`; - }); - return ["### Relevant detail (retrieved from your project knowledge — cite the [source] when you use it)", ...lines].join("\n"); -} - -/** - * Assemble Loki's read-only fleet context for a user + query. Breadth (all - * projects) + depth (RAG). Empty string when there's nothing to inject. - */ -export async function buildLokiFleetContext(userId: string, query: string): Promise { - const projects = await getUserProjects(userId).catch(() => [] as UserProject[]); - // Fleet breadth + RAG depth (the operator's own work) alongside live economy - // demand from OrangeCat (what to build for). Demand is best-effort — a slow or - // down OrangeCat degrades to plain fleet context, never blocks the turn. - const [detail, demand, econMatches] = await Promise.all([ - buildRetrievedDetail(userId, query), - fetchOpenDemand().then(buildDemandBlock).catch(() => ""), - searchEconomy(query).catch(() => []), - ]); - const index = buildFleetIndex(projects); - const econSearch = buildEconomySearchBlock(econMatches); - if (!index && !detail && !demand && !econSearch) return ""; - return [ - "## Fleet context (read-only background — the operator's current projects)", - "Use this to answer accurately and specifically about the operator's work. It is current state, NOT part of the conversation — and NOT something to repeat back. Never restate, summarise, or list this context to the user; answer their actual question directly and concisely, citing specific projects. If a question falls outside this context, say so rather than guessing.", - index, - detail, - econSearch, - demand, - ].filter(Boolean).join("\n\n"); -} diff --git a/src/lib/loki-fleet-index.ts b/src/lib/loki-fleet-index.ts deleted file mode 100644 index 299d16c01..000000000 --- a/src/lib/loki-fleet-index.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Pure formatting of Loki's fleet index (BREADTH half of its project awareness). - * Deliberately DB-free — imports only pure helpers + types — so it unit-tests in - * the env-independent suite. The DB/RAG orchestration lives in loki-fleet-context. - */ -import { cleanDescription } from "@/lib/project-display"; -import type { DevLogEntry, UserProject } from "@/db/schema/user-projects"; - -const INDEX_DESC_MAX = 140; -const INDEX_STATE_MAX = 160; - -function latestDevLogState(project: UserProject): string | null { - const log = (project.devLog as DevLogEntry[]) ?? []; - const last = log[log.length - 1]; - if (!last?.done) return null; - return `${last.date ? `${last.date}: ` : ""}${last.done}`.trim(); -} - -/** One compact line per project: name — stack — description — latest state. */ -export function buildFleetIndex(projects: UserProject[]): string { - if (projects.length === 0) return ""; - const lines = projects.map((p) => { - const desc = cleanDescription(p.description); - const state = latestDevLogState(p); - const parts = [ - `- **${p.name}**`, - p.stack ? `(${p.stack})` : "", - desc ? `— ${desc.slice(0, INDEX_DESC_MAX)}` : "", - state ? `· latest: ${state.replace(/\s+/g, " ").slice(0, INDEX_STATE_MAX)}` : "", - ].filter(Boolean); - return parts.join(" "); - }); - return [`### Your projects (${projects.length} active)`, ...lines].join("\n"); -} From 876d067a63f4551f0533e0a7c36ae0d651ec1d63 Mon Sep 17 00:00:00 2001 From: test Date: Thu, 13 Aug 2026 12:30:26 +0200 Subject: [PATCH 2/2] feat(agent): scope the name check by assistant, not one rule for both MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verifier's original closed-world rule — every proper noun must be attested in the records — is right for Loki and wrong for Cat, and shipping it unchanged to both would have made Cat unusable. Loki reports one operator's records. Any unattested name there is a fabrication. Cat also answers general questions ("how do I get paid in Switzerland"), where naming Twint, Lightning or PayPal is correct and required. Flagging those would have produced constant false positives on a live multi-tenant product, and a check that cries wolf gets switched off within a week — at which point it protects nothing. So `mode`: closed-world every unattested proper noun is a violation (Loki) entity-attribution only sentences naming one of the USER'S OWN records are checked (Cat) The narrower mode is genuinely weaker and that is a real trade, not a loophole: Cat can still be wrong about the wider world. It can no longer invent an employer for someone in your contacts — the failure that actually destroys trust in a personal assistant. Also adds buildAssistantRules() — the fact-id-free half of the contract, for an assistant whose context is still prose. Weaker by construction (nothing to cite), but it keeps the rules that stopped the worst failure: never state an attribute a record does not carry, never imply research you did not perform. A stepping stone to typed records, not a destination. Two new adversarial checks assert BOTH halves — general economic advice passes in entity-attribution mode, an invented employer for a known contact does not, and closed-world remains strictly stronger. Co-Authored-By: Claude Opus 5 --- scripts/test/agent-grounding.ts | 57 +++++++++++++++++++++++++++++++-- src/lib/agent/core/contract.ts | 27 ++++++++++++++++ src/lib/agent/core/verify.ts | 52 +++++++++++++++++++++++++++++- 3 files changed, 133 insertions(+), 3 deletions(-) diff --git a/scripts/test/agent-grounding.ts b/scripts/test/agent-grounding.ts index 84c3f065c..3dcbe2b81 100644 --- a/scripts/test/agent-grounding.ts +++ b/scripts/test/agent-grounding.ts @@ -30,7 +30,7 @@ import { unrecordedFields, NOT_RECORDED, } from "../../src/lib/agent/core/facts"; -import { buildContract, buildGroundedContext, renderDirectives, NO_BASIS } from "../../src/lib/agent/core/contract"; +import { buildContract, buildGroundedContext, renderDirectives, buildAssistantRules, NO_BASIS } from "../../src/lib/agent/core/contract"; import { verifyAnswer, buildRepairPrompt } from "../../src/lib/agent/core/verify"; // ── The real records, exactly as FleetCrown stores them ────────────────────── @@ -214,4 +214,57 @@ const USER_MSG = "Plan my day. Who should I reach out to and why?"; assert.ok(repair.includes(NO_BASIS), "repair prompt must offer the refusal phrase as the substitute"); } -console.log("✓ agent grounding: 10 adversarial checks passed (UZH, invented bio, invented paths, fake citations, correction path)"); +// ── 11. entity-attribution mode: Cat keeps general knowledge, loses invention ── +// Cat answers "how do I get paid in Switzerland" as well as "who owes me money". +// A closed-world check would flag Twint and Lightning as fabrications and make +// Cat useless, so the narrower mode only polices sentences about the user's own +// records. Both halves are asserted, because a check that is too strict gets +// switched off and then protects nothing. +{ + const general = verifyAnswer({ + answer: + "You can receive Bitcoin over the Lightning Network, or use Twint if your counterparty is in Switzerland. PayPal works internationally.", + facts: FACTS, + userMessage: "how can I get paid?", + mode: "entity-attribution", + }); + assert.equal( + general.ok, + true, + `general economic knowledge must pass in entity-attribution mode, got: ${JSON.stringify(general.violations)}`, + ); + + const attributed = verifyAnswer({ + answer: "Ask Elena Weber SINGA Switzerland — she is the Program Manager at Impact Hub Zurich.", + facts: FACTS, + userMessage: "who can help me with funding?", + mode: "entity-attribution", + }); + assert.equal(attributed.ok, false, "an invented affiliation for a known contact must still be caught"); + assert.ok( + attributed.violations.some((v) => /Impact Hub/i.test(v.text)), + "the fabricated employer must be named in the violation", + ); + + // The same general sentence IS flagged under closed-world, which is correct + // for Loki: reporting the operator's fleet has no need to name new companies. + const strict = verifyAnswer({ + answer: "You can receive Bitcoin over the Lightning Network, or use Twint.", + facts: FACTS, + userMessage: "how can I get paid?", + mode: "closed-world", + }); + assert.equal(strict.ok, false, "closed-world mode must be strictly stronger than entity-attribution"); +} + +// ── 12. The fact-free rules block still forbids the invention ──────────────── +{ + const rules = buildAssistantRules({ subjectNoun: "contacts and entities" }); + assert.match(rules, /not their employer/i, "the anti-affiliation-inference rule must survive"); + assert.match(rules, /have not browsed the web/i, "the no-research rule must survive"); + assert.match(rules, /General knowledge/, "general knowledge must be explicitly permitted"); + assert.ok(rules.includes(NO_BASIS), "the refusal phrase must be supplied"); + assert.doesNotMatch(rules, /\[F1\]/, "no citation ids exist without a fact set — none must be promised"); +} + +console.log("✓ agent grounding: 12 adversarial checks passed (UZH, invented bio, invented paths, fake citations, correction path, mode scoping)"); diff --git a/src/lib/agent/core/contract.ts b/src/lib/agent/core/contract.ts index d031457c4..680a5efdd 100644 --- a/src/lib/agent/core/contract.ts +++ b/src/lib/agent/core/contract.ts @@ -62,6 +62,33 @@ export function buildContract(facts: Fact[]): string { return rules.join("\n"); } +/** + * The subset of the contract that needs no fact ids — for an assistant whose + * context is still prose (Cat) rather than typed records. + * + * Weaker than `buildContract` by construction: without ids there is nothing to + * cite, so rule 1 cannot exist and the verifier runs in entity-attribution + * mode. What survives is the part that stopped the worst failure — never state + * an attribute for someone in the user's data that their record does not carry, + * and never imply research you did not perform. + * + * This is a stepping stone, not the destination. It exists so a live product + * gets the protection now, without a same-day rewrite of its whole context + * layer; the destination is typed records here too. + */ +export function buildAssistantRules(opts: { subjectNoun: string }): string { + return [ + "## Grounding rules — these override formatting instructions", + "", + `1. Everything you state about the user's own ${opts.subjectNoun} must come from the context above. Do not add an organisation, role, employer, history, or relationship that the context does not state.`, + "2. Do not infer an affiliation from a name. A word inside someone's name is not their employer or their city.", + "3. You have not browsed the web in this turn. If asked to research a person or company, say you cannot, and report only what the context holds.", + `4. If part of the request has no support in the context, answer that part with exactly "${NO_BASIS}" and continue with the parts you can support. A requested format never obliges you to invent an item.`, + "5. General knowledge (how Bitcoin, Lightning, or a payment method works) is fine to use and is not covered by rules 1–2. The restriction is on facts about THIS user and the people and organisations in their data.", + "6. A correction is a claim too. If you are correcting yourself, it must be supported by the context or stated as unknown.", + ].join("\n"); +} + /** * A deterministic answer computed by the app, not the model. * diff --git a/src/lib/agent/core/verify.ts b/src/lib/agent/core/verify.ts index 25103f699..0601f16c1 100644 --- a/src/lib/agent/core/verify.ts +++ b/src/lib/agent/core/verify.ts @@ -179,6 +179,37 @@ function pathClaims(text: string): string[] { return [...out]; } +/** + * How strictly to treat unattested names — the one real difference between the + * two assistants that use this harness. + * + * `closed-world` (Loki): the assistant's entire job is reporting the operator's + * records, so ANY unattested proper noun is a fabrication. One operator, one + * data set, no legitimate outside knowledge in scope. + * + * `entity-attribution` (Cat): the assistant also answers general questions — + * how Lightning works, which payment rails exist in Switzerland — where naming + * Twint or Bitcoin is correct and required. Flagging those would make Cat + * useless. So the check narrows to what actually goes wrong: attributes + * attached to one of the USER'S OWN records. A sentence naming a known subject + * is checked; a sentence of general explanation is not. + * + * The narrower mode is genuinely weaker, and that is a real trade, not a + * loophole: Cat can still invent a fact about the wider world. It can no longer + * invent an employer for someone in your contacts, which is the failure that + * actually destroys trust in a personal assistant. + */ +export type VerifyMode = "closed-world" | "entity-attribution"; + +/** Does this sentence talk about one of the user's own records? */ +function mentionsSubject(sentence: string, subjects: string[]): boolean { + const s = norm(sentence); + return subjects.some((sub) => { + const n = norm(sub); + return n.length > 2 && s.includes(n); + }); +} + /** * Verify an answer against the facts it was supposed to come from. * @@ -186,18 +217,37 @@ function pathClaims(text: string): string[] { * directive output, a tool result the model legitimately saw this turn. * Anything not in facts, the user's message, or extraEvidence is unsupported * by construction. + * + * `subjects` (entity-attribution mode) names the user's own records, so the + * check can tell "your contact Elena works at X" from "Lightning is instant". */ export function verifyAnswer(input: { answer: string; facts: Fact[]; userMessage: string; extraEvidence?: string[]; + mode?: VerifyMode; + subjects?: string[]; }): VerifyResult { const { answer, facts, userMessage } = input; + const mode: VerifyMode = input.mode ?? "closed-world"; + const subjects = input.subjects ?? facts.map((f) => f.subject); const evidence = buildEvidence(facts, userMessage, input.extraEvidence ?? []); const legalIds = new Set(facts.map((f) => f.id.toUpperCase())); const violations: Violation[] = []; + /** + * In entity-attribution mode, only sentences about the user's own records are + * subject to the name check. Built once so the per-token loop stays cheap. + */ + const attributionScope = + mode === "entity-attribution" + ? answer + .split(/(?<=[.!?:\n])\s+/) + .filter((s) => mentionsSubject(s, subjects)) + .join(" ") + : answer; + // 1. Citations must resolve. A citation to a record that does not exist is // the strongest possible signal of fabrication — it invents its own proof. for (const cite of answer.match(/\[F\d+\]/g) ?? []) { @@ -213,7 +263,7 @@ export function verifyAnswer(input: { // 2. Named entities must be traceable. This is the anti-"UZH" rule. const seen = new Set(); - for (const run of properNounRuns(answer)) { + for (const run of properNounRuns(attributionScope)) { const n = norm(run); if (!n || seen.has(n)) continue; seen.add(n);