wave 7: the prober reads its map — graph-context injection dies whole

The owner's zero-configuration ruling (2026-09-23): nothing configures
the graph beyond chat models. The prior-knowledge injection — its budget
dial, its runner, its marker part, its dock/dispatch entries — is
removed across all bands; the prober now reads the learner's persistent
knowledge map on demand through two gated tools instead:

- list_sessions: every birth session, names + kinds + counts + dates
  (graphSessionIndex, Platform's core seam)
- list_propositions(session): what was born there + the session's own
  latest understanding level + that evaluation's date
  (graphSessionPropositions) — names/levels/dating ONLY, no statement
  text (owner ruling: the map's organization is the prober's business,
  its prose is not)

Registered via createProberTools when chat-handler passes graphStore
(the graphToolsEnabled gate decided server-side, per-turn fresh); the
prober doctrine gains the map-review nudge at session start.

Also: runner's JobKind special-casing collapses (KIND_ROLE/KIND_RUNNERS
are plain records again), semantic-clusters.ts dies with its test, the
embedder dep's doc tells the wave-7 stub story, and ports.ts's
readClusterLabels/readNamingRuns surface stays untouched for Platform's
ledger-port interlude.

848/848 tests, typecheck/lint/build green.
This commit is contained in:
amosblomqvist
2026-09-23 15:24:48 +03:00
parent a72753b059
commit 6b13583e96
40 changed files with 373 additions and 1990 deletions
+5
View File
@@ -12,6 +12,7 @@ import { effectiveSettings, stores } from "./stores.ts";
import { searchAvailable, searchProvider } from "./search.ts";
import {
extractionEnabled,
graphToolsEnabled,
resolveConversationSpec,
resolveRoleSpec,
} from "../core/settings-resolution.ts";
@@ -274,6 +275,10 @@ export async function handleChatRequest(
jobStore: stores.jobStore,
spawn: (job, input) => void jobRunner.runJob(job.id, input),
searchAvailable,
// The map reader (wave 7): the prober reads the learner's
// knowledge graph through tools when the gate opens —
// graph master switch AND the agent.graphTools toggle.
...(graphToolsEnabled(effective) ? { graphStore: stores.graphStore } : {}),
},
agentOpts,
);
+6 -55
View File
@@ -34,7 +34,6 @@
import type { UIMessage } from "ai";
import {
buildAbortSteer,
buildGraphContextSteer,
buildSubagentSteer,
isSubagentFailure,
steerDelivered,
@@ -61,9 +60,6 @@ interface QueuedSteer {
interface WakeState {
turnRunner?: TurnRunner;
respawn?: JobRespawn;
/** Crash recovery for context-injection jobs (they are not sub-agent
* loops — their runner takes the graph deps; chat-handler wires it). */
contextRespawn?: (jobId: string) => void;
/** chatId → steers awaiting delivery, in arrival order. */
queues: Map<string, QueuedSteer[]>;
/** Chats whose pump loop is currently active (serialization per chat). */
@@ -91,13 +87,6 @@ export function setJobRespawn(respawn: JobRespawn): void {
state().respawn = respawn;
}
/** chat-handler wires this at module init: the graph-context runner
* (subagents/graph-context.ts runGraphContextJob — re-runnable from the
* job record alone, which is all crash recovery has). */
export function setContextRespawn(respawn: (jobId: string) => void): void {
state().contextRespawn = respawn;
}
const POLL_MS = 400;
/** After the gates open, wait a beat before firing — a client that just
* answered a question is about to send its continuation; don't 409 it.
@@ -124,11 +113,7 @@ function delay(ms: number): Promise<void> {
/** The job a steer belongs to, if it is a job steer (result/abort/context);
* probe-handoff steers have no job — null. */
function jobIdOf(steer: SteerMessage): string | null {
const meta = steer.metadata as {
subagentResult?: { jobId: string };
graphContext?: { jobId: string };
};
return meta.subagentResult?.jobId ?? meta.graphContext?.jobId ?? null;
return (steer.metadata as { subagentResult?: { jobId: string } }).subagentResult?.jobId ?? null;
}
/** Finalize a delivered steer's job bookkeeping — job steers only. */
@@ -250,12 +235,6 @@ export function abortJob(jobId: string): AbortOutcome {
if (!job) return { ok: false, reason: "not_found" };
if (job.status !== "running") return { ok: false, reason: "not_running" };
// Context-injection jobs abort SILENTLY: nothing was ever promised in the
// lesson (server-initiated scaffolding, unlike a teacher-dispatched
// sub-agent), so no abort steer — flipping the record and killing the
// run is the whole story. Steps 1–3 below are shared; step 4 is skipped.
const isContext = job.kind === "graph-context";
fireJobAbort(jobId); // no-op when the loop already returned (delivering)
// Drop any queued steers for this job (their deliver() resolves unfired).
@@ -270,22 +249,17 @@ export function abortJob(jobId: string): AbortOutcome {
}
stores.jobStore.updateJob(jobId, { status: "error", error: "Aborted by learner", activity: "aborted" });
if (!isContext) {
void deliverJobResult(buildAbortSteer(job)).catch(() => {
/* deliverJobResult marks the job error on unrecoverable failure */
});
}
void deliverJobResult(buildAbortSteer(job)).catch(() => {
/* deliverJobResult marks the job error on unrecoverable failure */
});
return { ok: true };
}
function chatIdOf(steer: SteerMessage): string {
// The probe handoff and the context injection carry their chat directly
// (the context steer's job record would say it too, but the metadata is
// the cheap path — same rule as the handoff).
// The probe handoff carries its chat directly (the job record would
// say it too, but the metadata is the cheap path).
const handoff = (steer.metadata as { probeHandoff?: { chatId: string } }).probeHandoff;
if (handoff) return handoff.chatId;
const injected = (steer.metadata as { graphContext?: { chatId: string } }).graphContext;
if (injected) return injected.chatId;
// The chat is resolvable from the job record; the steer's metadata
// carries jobId, which indexes the store.
const jobId = jobIdOf(steer);
@@ -394,12 +368,6 @@ async function pump(chatId: string): Promise<void> {
function finalizeJob(jobId: string): void {
const job = stores.jobStore.getJob(jobId);
if (!job || job.status !== "running") return;
// Context-injection jobs have no sub-agent result shape — a delivered
// block is a success, full stop (isSubagentFailure would misread it).
if (job.kind === "graph-context") {
stores.jobStore.updateJob(jobId, { status: "done", activity: "delivered" });
return;
}
const result = job.result;
const failed = result ? isSubagentFailure(job.kind, result as never) : true;
stores.jobStore.updateJob(jobId, {
@@ -474,23 +442,6 @@ export function recoverStaleJobs(): void {
if (isJobLive(job.id)) continue; // provably executing here — never re-run beside it
if (!stores.chatStore.getChat(job.chatId)) continue;
if (isTurnRunning(job.chatId)) continue; // a wake turn may be delivering it right now
if (job.kind === "graph-context") {
// Context-injection orphans: a stored block re-delivers in its own
// steer shape; a job that died before selecting re-runs from the
// record (the graph runner — not the sub-agent respawn seam).
if (job.result) {
stores.jobStore.updateJob(job.id, { activity: "recovering: re-delivering" });
void deliverJobResult(
buildGraphContextSteer({ chatId: job.chatId, jobId: job.id }, job.result as never),
).catch(() => {
/* deliverJobResult marks the job error on failure */
});
} else if (s.contextRespawn) {
stores.jobStore.updateJob(job.id, { activity: "recovering: re-running" });
s.contextRespawn(job.id);
}
continue;
}
if (job.result) {
// The wake never fired (or crashed mid-flight): re-deliver.
const steer = buildSubagentSteer(job, job.result as never);
+1 -9
View File
@@ -1,8 +1,7 @@
import type { UIMessage } from "ai";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, Message, ToolResultMessage } from "@earendil-works/pi-ai";
import { graphContextOfMessage } from "../../core/graph-context.ts";
import { graphContextText, HANDOFF_TEXT, verdictText } from "../../core/contracts/chat.ts";
import { HANDOFF_TEXT, verdictText } from "../../core/contracts/chat.ts";
/**
* The record → pi-ai transcript adapter (docs/pi-engine-swap.md §4). The
@@ -62,13 +61,6 @@ function systemTextOf(message: { parts: ReadonlyArray<{ type: string; data?: unk
return verdictText((part.data as { accepted?: unknown } | undefined)?.accepted === true);
}
if (part.type === "data-probe-handoff") return HANDOFF_TEXT;
// Graph context injection: the block text derives from the part's
// validated data (parse-don't-cast — a malformed part has no model
// face, and the message then never reaches the provider).
if (part.type === "data-graph-context") {
const data = graphContextOfMessage({ parts: message.parts });
return data ? graphContextText(data) : null;
}
}
return null;
}
+5 -19
View File
@@ -12,7 +12,7 @@
*/
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { GRAPH_CONTEXT_TOKENS_MAX, isHexColor, type ChatSettings } from "../core";
import { isHexColor, type ChatSettings } from "../core";
import type {
AgentSettings,
AppearanceSettings,
@@ -37,7 +37,6 @@ export function parseSettingsBody(body: unknown): SettingsBodyResult<ChatSetting
const raw = body as {
model?: { provider?: unknown; id?: unknown };
thinkingLevel?: unknown;
graphContextTokens?: unknown;
};
const settings: ChatSettings = {};
if (raw.model !== undefined) {
@@ -57,23 +56,10 @@ export function parseSettingsBody(body: unknown): SettingsBodyResult<ChatSetting
}
settings.thinkingLevel = raw.thinkingLevel as ThinkingLevel;
}
// Graph context injection budget (vision-context-injection.md): integer
// tokens, 0 (off) to GRAPH_CONTEXT_TOKENS_MAX, no silent clamping — the
// slider is the only writer, and it only ever sends multiples of the step.
if (raw.graphContextTokens !== undefined) {
if (
typeof raw.graphContextTokens !== "number" ||
!Number.isInteger(raw.graphContextTokens) ||
raw.graphContextTokens < 0 ||
raw.graphContextTokens > GRAPH_CONTEXT_TOKENS_MAX
) {
return {
ok: false,
error: `graphContextTokens must be an integer between 0 and ${GRAPH_CONTEXT_TOKENS_MAX}`,
};
}
settings.graphContextTokens = raw.graphContextTokens;
}
// Wave 7 killed the graph-context injection (owner: zero configuration
// beyond chat models — the prober reads its map through tools now); its
// budget dial is gone with it. A stale client sending the old key has
// it silently dropped here (forward-compatible, like any unknown key).
return { ok: true, settings };
}