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:
amosblomqvist
2026-09-15 07:54:50 +03:00
parent ecb293a77d
commit 1d45d77303
25 changed files with 1443 additions and 28 deletions
+5 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIcon, BotIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
import { ActivityIcon, BookOpenIcon, BotIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
import { CONTEXT_LIMITS, jobCost } from "@/src/core";
@@ -204,6 +204,7 @@ const KIND_ICON: Record<JobKind, typeof SearchIcon> = {
research: SearchIcon,
demo: PlayIcon,
visual: PenToolIcon,
"graph-context": BookOpenIcon,
};
/** Abort states per blob id: pending (fired) / failed (backend absent). */
@@ -318,7 +319,9 @@ function ActivityBlobRow({
? "Demo builder"
: blob.jobKind === "visual"
? "Figure maker"
: "Sub-agent";
: blob.jobKind === "graph-context"
? "Knowledge context"
: "Sub-agent";
return (
<div
data-testid={`activity-blob-${blob.process}`}
+3
View File
@@ -21,6 +21,9 @@ const KIND_LABEL: Record<JobKind, string> = {
research: "Research agent",
demo: "Demo agent",
visual: "Figure agent",
// The injection is server-initiated — it has no tool part, so never a
// dispatch card. The entry exists only for the exhaustive record.
"graph-context": "Knowledge context",
};
export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: JobKind }) {
+20 -2
View File
@@ -21,7 +21,8 @@ import { SubagentSteerCard } from "@/components/artifacts/steer-card";
import { Composer } from "@/components/composer";
import { JobsContext } from "@/components/jobs-context";
import { StatsBar } from "@/components/stats-bar";
import type { ChatSettings } from "@/src/core";
import { graphContextOfMessage, type ChatSettings } from "@/src/core";
import { formatTokens } from "@/components/usage-format";
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
import { PlanMarginalia } from "@/components/plan-window";
import { WaypointsIcon } from "lucide-react";
@@ -81,6 +82,16 @@ const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayout
/** A wake-pipeline steer message: user-role delivery whose metadata marks it
* as a sub-agent result — rendered as an artifact card, never a bubble. */
/** The knowledge-context card's one-liner: the numbers the observability
* doctrine wants visible at the seam — tokens and proposition count
* (parse-don't-cast; a malformed part still renders a quiet generic line). */
function graphContextLine(message: { parts: ReadonlyArray<{ type: string; data?: unknown }> }): string {
const data = graphContextOfMessage(message);
return data
? `Knowledge context — ~${formatTokens(data.tokens)} · ${data.propositions.length} propositions`
: "Knowledge context";
}
/** The quiet system line (custom-message scheme rendering): centered, muted,
* small — the transcript's voice for events that are not speech (wake
* scaffolding, the learner's plan verdict). */
@@ -540,7 +551,14 @@ export function Chat({
// PROTOCOL" §4). Key fallback: legacy transcripts (pre id-hygiene)
// can still hold empty ids — the index keeps keys unique for them.
<SubagentSteerCard key={m.id || `m-${mi}`} message={m} />
) : m.role === "user" && m.parts.some((p) => p.type === "data-probe-handoff") ? (
) : m.role === "user" && m.parts.some((p) => p.type === "data-graph-context") ? (
// The prior-knowledge injection's delivery (vision-context-injection.md):
// quiet system line, the numbers only — the block itself was for the
// model (adapter flattening); the part data stays the audit trail.
<SystemLine key={m.id || `m-${mi}`} testId="graph-context">
{graphContextLine(m)}
</SystemLine>
) : m.role === "user" && m.parts.some((p) => p.type === "data-probe-handoff") ? (
// The probe→teach handoff wake (custom-message scheme): quiet
// system line, never a user bubble — the wake is scaffolding,
// the "Planning" divider marks the seam, and the model-facing
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { BookOpenIcon } from "lucide-react";
import {
GRAPH_CONTEXT_TOKENS_DEFAULT,
GRAPH_CONTEXT_TOKENS_MAX,
GRAPH_CONTEXT_TOKENS_STEP,
} from "@/src/core";
import { formatTokens } from "@/components/usage-format";
/**
* The knowledge-context budget control (vision-context-injection.md) — the
* "session data" section's one input, shown where sessions START: the home
* (graph) view's new-session dock and the /chat/new draft. Input before
* output: the panel that will later show what the session contributed to
* the graph asks first what the graph will contribute to the session.
*
* One quiet slider row: the token budget for the prior-knowledge block the
* injection steers in at session start. 0 = off; absent settings show the
* DEFAULT (1024 — on). The value rides ChatSettings (the same lifted
* settings the model picker uses), so it persists pre-chat via the same
* deferred PUT. Sessions never show this control — the injection is
* one-shot at session start, so mid-session there is nothing to decide.
*/
export function GraphContextControl({
value,
onChange,
testId = "graph-context-control",
}: {
value: number;
onChange: (tokens: number) => void;
testId?: string;
}) {
return (
<div
data-testid={testId}
className="flex items-center gap-2 px-1 text-xxs text-muted-foreground"
>
<BookOpenIcon className="size-3 shrink-0" />
<span className="shrink-0 uppercase tracking-widest">Knowledge context</span>
<input
type="range"
min={0}
max={GRAPH_CONTEXT_TOKENS_MAX}
step={GRAPH_CONTEXT_TOKENS_STEP}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
aria-label="Prior-knowledge token budget"
className="h-1 flex-1 accent-primary"
data-testid="graph-context-slider"
/>
<span
className="w-16 shrink-0 text-right tabular-nums"
data-testid="graph-context-value"
>
{value === 0 ? "off" : `${formatTokens(value)} tokens`}
</span>
</div>
);
}
/** The control's effective value for a chat's settings — absent = the
* default (on), never 0: the decided default is a modest slice of prior
* knowledge for every fresh session. */
export function effectiveContextTokens(settings: { graphContextTokens?: number } | undefined): number {
return settings?.graphContextTokens ?? GRAPH_CONTEXT_TOKENS_DEFAULT;
}
+12
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { CornerDownLeftIcon } from "lucide-react";
import { GraphView } from "@/components/graph-view";
import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control";
import { SidebarToggle } from "@/components/sidebar";
import { StatsBar } from "@/components/stats-bar";
import type { ChatSettings, TurnUsage } from "@/src/core";
@@ -71,6 +72,17 @@ export function Home() {
px-4) so nothing shifts when a session starts (owner). */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 pb-5">
<div className="pointer-events-auto mx-auto w-full max-w-[46rem] px-4">
{/* The session-data strip (vision-context-injection.md): what the
graph will contribute to the session, decided before the session
starts. Rides the same lifted settings as the model picker. */}
<div className="mb-1.5">
<GraphContextControl
value={effectiveContextTokens(settings)}
onChange={(tokens) =>
setSettings((s) => ({ ...s, graphContextTokens: tokens }))
}
/>
</div>
<form
className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg"
onSubmit={(e) => {
+3 -1
View File
@@ -245,7 +245,9 @@ export function ModelControl({
effectiveLevel !== undefined && m.supportedLevels.includes(effectiveLevel)
? effectiveLevel
: undefined;
void save({ model: { provider: m.provider, id: m.id }, thinkingLevel: nextLevel });
// Replace-whole discipline: spread the current settings — a model pick
// must not silently clear a sibling override (graphContextTokens).
void save({ ...(settings ?? {}), model: { provider: m.provider, id: m.id }, thinkingLevel: nextLevel });
}}
className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${
m.id === effectiveId ? "bg-muted/60" : ""
+11
View File
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import { Conversation, ConversationContent, ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Composer } from "@/components/composer";
import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control";
import { PhaseChip } from "@/components/phase-divider";
import { SidebarToggle } from "@/components/sidebar";
import { StatsBar } from "@/components/stats-bar";
@@ -98,6 +99,16 @@ export function DraftShell({
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
<div className="bg-background pb-4">
<div className={DOCK}>
{/* The session-data strip (vision-context-injection.md) — same
control as the home view, riding the same lifted settings. */}
<div className="mb-1.5">
<GraphContextControl
value={effectiveContextTokens(settings)}
onChange={(tokens) =>
onSettingsSaved({ ...settings, graphContextTokens: tokens })
}
/>
</div>
<div className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
<Composer
busy={busy}