feat: graph context injection — the session's prior knowledge, steered in at session start (vision-context-injection.md)
One shot per chat, fired at the first turn's onFinish: the session's topic (its only typed message) is embedded as an EmbeddingGemma retrieval query; the semantically closest graph propositions are selected under the session's token budget (pure budget, no similarity threshold — the decided doctrine) and delivered as a marker-only steer through the wake pipeline, queuing behind pending questions like every sub-agent result. - contracts: ChatSettings.graphContextTokens (default 1024, max 4096, step 128, 0 = off); GraphContextData + graphContextText; the graph-context JobKind + buildGraphContextSteer (id steer_ctx_<chatId>) - core/graph-context.ts: brute-force cosine top-K, greedy chars/4 fill under budget, most-recent UnderstandingLevel fold, count cap 64, parse-don't-cast at the marker seam - server: embedQuery (role-asymmetric query format, same warm singleton — vector-space parity by construction); the runner (startGraphContext- Injection one-shot gates; runGraphContextJob validates ledger parity); wake branches (chatIdOf/jobIdOf, finalizeJob done, recoverStaleJobs re-deliver or contextRespawn); abortJob kills context jobs SILENTLY (nothing was promised in the lesson); turn-service trigger seam - adapter: data-graph-context flattens to the canonical block (malformed parts have no model face) - UI: the budget slider (home dock + /chat/new draft, the session-data strip); the quiet SystemLine "~537 · 10 propositions"; dock kind "Knowledge context"; model-picker PUT now spreads settings (a model pick can no longer clear the sibling override) Live-smoked end to end on the real embedder + real GLM: seeded ledger (10 propositions), session on "modal logic: possible worlds and the necessity box" — job in the dock, queued behind the prober's question, delivered on the answer, SystemLine in the transcript, wake turn reads the block and continues the probe. Gates: tsc, lint, 459/459, build.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Graph context injection — the background runner
|
||||
* (docs/vision-context-injection.md, settled 2026-09-14).
|
||||
*
|
||||
* One shot per chat, at session start: the first learner message is the
|
||||
* session's TOPIC; it is embedded as a retrieval QUERY (the embedder's
|
||||
* model-card format), the semantically closest graph propositions are
|
||||
* selected under the session's token budget (pure core: selectGraphContext),
|
||||
* and delivered as a marker-only STEER through the wake pipeline — the same
|
||||
* gates as sub-agent results: it queues behind a pending quiz and rides the
|
||||
* turn after the learner's answer. The prober typically opens with a run of
|
||||
* quiz/ask calls, so delivery lands mid-probing, before plan_init — where
|
||||
* prior knowledge matters most.
|
||||
*
|
||||
* No model call, no usage: the job's only cost is one embedding pass over
|
||||
* the warm singleton (tens–~200ms) plus a brute-force cosine scan. It still
|
||||
* carries a JobRecord (kind "graph-context") so the dock shows it, and it
|
||||
* registers its AbortController so the dock's END kills it — but an abort
|
||||
* steers NOTHING: nothing was ever promised in the lesson (the injection is
|
||||
* server-initiated scaffolding, unlike a teacher-dispatched sub-agent).
|
||||
*
|
||||
* The topic gate is structural: injection fires only while the session has
|
||||
* EXACTLY ONE text-bearing user message — the topic. Old in-flight sessions
|
||||
* (several typed messages) never inject (no retroactive injection), and a
|
||||
* session whose first turn happened with injection off stays off even if
|
||||
* the budget is raised later (set per-session, before the session starts).
|
||||
*/
|
||||
|
||||
import {
|
||||
buildGraphContextSteer,
|
||||
steerDelivered,
|
||||
type JobRecord,
|
||||
type SteerMessage,
|
||||
} from "../../core/contracts.ts";
|
||||
import {
|
||||
GRAPH_CONTEXT_TOKENS_DEFAULT,
|
||||
type ChatSettings,
|
||||
} from "../../core/contracts/chat.ts";
|
||||
import { hasContext, selectGraphContext } from "../../core/graph-context.ts";
|
||||
import type { UIMessage } from "ai";
|
||||
import { registerJobAbort, unregisterJobAbort } from "./aborts.ts";
|
||||
import type { ChatStore, GraphStore, JobStore } from "../../core/ports.ts";
|
||||
|
||||
/** The embedder as the runner needs it: the model/dim the ledger must
|
||||
* match (parity: one vector space) + the query seam. Injected so tests
|
||||
* run without the transformer model. */
|
||||
export interface GraphContextEmbedder {
|
||||
model: string;
|
||||
dim: number;
|
||||
embedQuery: (text: string) => Promise<number[]>;
|
||||
}
|
||||
|
||||
export interface GraphContextDeps {
|
||||
chatStore: Pick<ChatStore, "getChat">;
|
||||
jobStore: Pick<JobStore, "createJob" | "getJob" | "updateJob" | "findJobByToolCallId">;
|
||||
graphStore: Pick<GraphStore, "readNodes" | "readEvents" | "readEmbeddings">;
|
||||
embedder: GraphContextEmbedder;
|
||||
/** The wake seam — jobs.ts deliverJobResult (resolves on delivery). */
|
||||
deliver: (steer: SteerMessage) => Promise<void>;
|
||||
}
|
||||
|
||||
/** The injection job's synthetic toolCallId — no tool part exists; the id
|
||||
* is deterministic per chat so findJobByToolCallId doubles as the one-shot
|
||||
* guard, mirroring the spawn-dedup the tool jobs get for free. */
|
||||
export function contextToolCallId(chatId: string): string {
|
||||
return `ctx_${chatId}`;
|
||||
}
|
||||
|
||||
/** The session's effective budget: settings override, else the default.
|
||||
* Absent ≠ 0 — the decided default is ON (1024 tokens). */
|
||||
export function effectiveContextBudget(settings: ChatSettings | undefined): number {
|
||||
return settings?.graphContextTokens ?? GRAPH_CONTEXT_TOKENS_DEFAULT;
|
||||
}
|
||||
|
||||
/** The session's topic: its ONLY text-bearing user message, if exactly one
|
||||
* exists. Steers (verdicts, handoffs, answers) carry no text part, so
|
||||
* quiz rounds during probing never break the gate. */
|
||||
export function topicOf(messages: UIMessage[]): string | null {
|
||||
let topic: string | null = null;
|
||||
let count = 0;
|
||||
for (const m of messages) {
|
||||
if (m.role !== "user") continue;
|
||||
const text = m.parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => (p as { text?: unknown }).text)
|
||||
.filter((t): t is string => typeof t === "string" && t.trim().length > 0)
|
||||
.join("\n");
|
||||
if (text.length === 0) continue;
|
||||
count++;
|
||||
topic = text;
|
||||
}
|
||||
return count === 1 ? topic : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-shot trigger (fire-and-forget, called from turn-service at every
|
||||
* turn's onFinish): starts the injection job if and only if the session is
|
||||
* at its start (exactly one typed message — the topic), the budget is on,
|
||||
* and nothing was injected before (delivered steer OR any prior context
|
||||
* job — success or failure: one shot means one shot). Returns the created
|
||||
* job for tests; null when nothing was started.
|
||||
*/
|
||||
export function startGraphContextInjection(deps: GraphContextDeps, chatId: string): JobRecord | null {
|
||||
const { chatStore, jobStore } = deps;
|
||||
const chat = chatStore.getChat(chatId);
|
||||
if (!chat) return null;
|
||||
if (effectiveContextBudget(chat.settings) <= 0) return null;
|
||||
if (topicOf(chat.messages) === null) return null;
|
||||
// One per chat, ever: a delivered steer OR a prior job (any status).
|
||||
if (steerDelivered(chat.messages, `steer_ctx_${chatId}`)) return null;
|
||||
if (jobStore.findJobByToolCallId(chatId, contextToolCallId(chatId))) return null;
|
||||
|
||||
const job = jobStore.createJob({
|
||||
kind: "graph-context",
|
||||
chatId,
|
||||
toolCallId: contextToolCallId(chatId),
|
||||
activity: "searching knowledge graph",
|
||||
});
|
||||
void runGraphContextJob(deps, job.id).catch((err: unknown) => {
|
||||
console.error(`[chat ${chatId}] graph-context injection crashed:`, err);
|
||||
});
|
||||
return job;
|
||||
}
|
||||
|
||||
/** Run one injection job to completion (background; never throws). Also the
|
||||
* crash-recovery respawn seam: a job record orphaned "running" by a
|
||||
* process crash re-runs from here (the record is the only input needed). */
|
||||
export async function runGraphContextJob(deps: GraphContextDeps, jobId: string): Promise<void> {
|
||||
const { chatStore, jobStore, graphStore, embedder, deliver } = deps;
|
||||
const job = jobStore.getJob(jobId);
|
||||
if (!job || job.status !== "running") return;
|
||||
|
||||
const fail = (reason: string) => {
|
||||
jobStore.updateJob(jobId, { status: "error", error: reason, activity: "failed" });
|
||||
};
|
||||
const chat = chatStore.getChat(job.chatId);
|
||||
if (!chat) return fail(`chat not found: ${job.chatId}`);
|
||||
const topic = topicOf(chat.messages);
|
||||
if (topic === null) return fail("no injectable topic (session past its start)");
|
||||
const budget = effectiveContextBudget(chat.settings);
|
||||
|
||||
// Real-kill wiring: the dock's END fires this controller; the runner
|
||||
// checks it before every phase transition and unwinds silently.
|
||||
const controller = new AbortController();
|
||||
registerJobAbort(jobId, controller);
|
||||
try {
|
||||
jobStore.updateJob(jobId, { activity: "embedding topic" });
|
||||
const ledger = graphStore.readEmbeddings();
|
||||
if (ledger.model !== embedder.model || ledger.dim !== embedder.dim) {
|
||||
return fail("embeddings ledger is stale (model/dim mismatch) — injection skipped");
|
||||
}
|
||||
if (Object.keys(ledger.vectors).length === 0) {
|
||||
return fail("the knowledge graph has no embeddings yet — nothing to inject");
|
||||
}
|
||||
const query = await embedder.embedQuery(topic);
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
jobStore.updateJob(jobId, { activity: "selecting propositions" });
|
||||
const data = selectGraphContext({
|
||||
chatId: job.chatId,
|
||||
query,
|
||||
vectors: ledger.vectors,
|
||||
nodes: graphStore.readNodes(),
|
||||
events: graphStore.readEvents(),
|
||||
budget,
|
||||
});
|
||||
if (!hasContext(data)) return fail("nothing relevant fit the budget");
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
// The result rides the record (crash recovery re-delivers it); the
|
||||
// wake pipeline flips the job done when the lesson has resumed.
|
||||
jobStore.updateJob(jobId, { result: data, activity: "delivering context" });
|
||||
try {
|
||||
await deliver(buildGraphContextSteer({ chatId: job.chatId, jobId }, data));
|
||||
} catch (err) {
|
||||
console.error(`[chat ${job.chatId}] graph-context steer delivery failed:`, err);
|
||||
fail("context delivery failed");
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return; // the abort path owns the record
|
||||
console.error(`[chat ${job.chatId}] graph-context injection failed:`, err);
|
||||
fail(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
unregisterJobAbort(jobId);
|
||||
}
|
||||
}
|
||||
@@ -89,11 +89,14 @@ interface LoopContext {
|
||||
* table mirrors MODELS in config.ts (research→researcher, demo→demoBuilder,
|
||||
* visual→visualMaker); the id is taken from config so a price-table sync
|
||||
* re-prices jobs for free. Model overrides (tests) still bill under the
|
||||
* configured model — usage is what is being captured, not which instance. */
|
||||
* configured model — usage is what is being captured, not which instance.
|
||||
* Context-injection jobs make NO model call ("" — onUsage never fires for
|
||||
* them; the kind is deliberately absent from the runners below). */
|
||||
const KIND_MODEL: Record<JobKind, string> = {
|
||||
research: MODELS.researcher.model,
|
||||
demo: MODELS.demoBuilder.model,
|
||||
visual: MODELS.visualMaker.model,
|
||||
"graph-context": "",
|
||||
};
|
||||
|
||||
export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
@@ -128,11 +131,15 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
return jobStore.getJob(jobId)?.status === "running";
|
||||
}
|
||||
|
||||
/** The kind→runner map: one sub-agent loop per job kind. */
|
||||
/** The kind→runner map: one sub-agent loop per SUB-AGENT kind.
|
||||
* graph-context is deliberately absent: injection jobs are not
|
||||
* sub-agent loops (no model, graph deps instead) — their spawn and
|
||||
* crash-respawn seam is subagents/graph-context.ts, and runJob refuses
|
||||
* them below rather than guessing wrong. */
|
||||
// Each kind's runner narrows the union input itself (dispatch-time inputs
|
||||
// are kind-correct by construction; transcript fallbacks are cast after).
|
||||
const KIND_RUNNERS: Record<
|
||||
JobKind,
|
||||
Exclude<JobKind, "graph-context">,
|
||||
(jobId: string, input: JobToolInput | undefined, loop: LoopContext) => Promise<void>
|
||||
> = {
|
||||
research: (jobId, input, loop) => runResearchJob(jobId, researchModelOverride.current, input, loop),
|
||||
@@ -147,6 +154,12 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
return;
|
||||
}
|
||||
if (job.status !== "running") return; // already finished / re-run after crash recovery
|
||||
if (job.kind === "graph-context") {
|
||||
// Not a sub-agent: the injection's own respawn seam
|
||||
// (setContextRespawn → runGraphContextJob) owns these records.
|
||||
console.error(`[job ${jobId}] graph-context job reached the sub-agent runner — wrong seam`);
|
||||
return;
|
||||
}
|
||||
const run = KIND_RUNNERS[job.kind];
|
||||
if (!run) {
|
||||
console.error(`[job ${jobId}] unknown kind: ${job.kind}`);
|
||||
|
||||
Reference in New Issue
Block a user