feat: Wave C pivot - steer markers, not follow-ups

Owner correction (2026-09-26, later): a real lesson is ONE continuous
teach->quiz->teach run - there are no run-would-stop moments mid-lesson,
and the run ends that DO exist are quiz stopper exits where delivery
must not fire. Subagent results must land BETWEEN messages INSIDE the
run, never splitting a toolCall from its toolResult.

Vehicle 1 now hands markers to agent.steer() - pi's STEERING queue:
the loop drains it at TURN BOUNDARIES (after a completed assistant
message AND its tool results - the bonded pair survives injection) and
again at RUN START (a continuation drains before its first LLM call),
then CONTINUES the run with the marker in context.
steeringMode "all" replaces followUpMode.

registerLiveAgent moved BEFORE runPiTurn and now hands pending markers
itself: the start poll drains the queue SYNCHRONOUSLY inside the
runPiTurn call, so a later registration would miss it. A marker
stranded by a quiz-terminated run therefore rides the ANSWER run's
first LLM call - the native replacement for the old continuation
coalescing.

Settlement reconciliation, the idle wake, probe-handoff exclusion,
and the registry-as-truth design are UNCHANGED; stranding is now
exactly the stopper-exit case.

Tests: jobs 45/45 (fakeAgent.steer; new pin - registration steers
pending markers into the new run, parked wake never fires); chat-flow
24/24 (new e2e through the real composition - a gated mid-run turn, a
marker delivered while the turn streams, record shape
[call, result, MARKER, response] with the marker adjacent to the
completed unit, the model SEES the marker in its next prompt, zero
wakes); full suite 999/999, tsc/lint/build green.

Live smoke (PORT=3004): research dispatched in the probe turn parked
behind the dangling quiz; on the quiz answer the marker injected at
the answer run's START poll - record [answer result, marker, teacher
response], job flipped done/delivered at settlement, brief card
rendered with its sources toggle.

Plan doc: docs/PI-NATIVE-MIGRATION-PLAN.md gains the pivot section.
Deferred (owner list): thinking blocks rendering after messages;
vanishing tool-call output tokens.
This commit is contained in:
amosblomqvist
2026-09-26 04:31:07 +03:00
parent 7f51259fc6
commit ad0a6e5201
6 changed files with 239 additions and 55 deletions
+33
View File
@@ -589,3 +589,36 @@ again — Wave C is the structural migration onto pi's own delivery seams.
2. Client `applyWireEvent` non-assistant appends. 2. Client `applyWireEvent` non-assistant appends.
3. Pump simplification + `claimSteers` deletion + handler cleanup. 3. Pump simplification + `claimSteers` deletion + handler cleanup.
4. Test migration (jobs, chat-flow, session-core) + live smoke. 4. Test migration (jobs, chat-flow, session-core) + live smoke.
## Wave C pivot (owner correction, later 2026-09-26): follow-ups → STEERING
The owner re-examined the follow-ups-only ruling and overturned it: a real
learning session is ONE continuous run — teach a thing, verify it with a
quiz tool call, begin teaching the next thing. There are no "run would
otherwise stop" moments mid-lesson, and the few run ends that do exist are
exactly the ones where delivery must NOT happen (the question stopper). So
subagent results must be delivered MID-RUN, BETWEEN messages.
The pivot (all in the same two-vehicle skeleton):
- Vehicle 1 hands markers to `agent.steer()` — pi's STEERING queue. The
loop drains it at TURN BOUNDARIES: after a completed assistant message
AND its tool results (never between a toolCall and its toolResult — the
bonded pair survives injection), and again at RUN START (a continuation
drains the queue BEFORE its first LLM call). The run CONTINUES with the
marker in context.
- `steeringMode: "all"` (was `followUpMode`) — every handed marker joins
one boundary injection.
- `registerLiveAgent` moved BEFORE `runPiTurn` in the handler and now
hands pending markers itself: the loop's start poll drains the queue
SYNCHRONOUSLY inside the `runPiTurn` call, so a later registration would
miss it. A marker stranded by a quiz-terminated run therefore rides the
ANSWER run's first LLM call — the native replacement for the old
continuation coalescing.
- Settlement reconciliation, the idle wake, probe-handoff exclusion, and
the registry-as-truth design are UNCHANGED (stranding is now exactly the
stopper-exit case; the wake still covers a cold-and-idle queue).
Deferred after the pivot (owner's list): thinking blocks sometimes
rendering after messages; output tokens of certain tool calls vanishing
with no tool call actually happening.
+8 -6
View File
@@ -165,12 +165,14 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
}, },
streamFn: engine.streamFn, streamFn: engine.streamFn,
shouldStopAfterTurn: stopper, shouldStopAfterTurn: stopper,
// Wave C (2026-09-26): markers handed to a live run drain from pi's own // Wave C (2026-09-26, owner correction same day): markers handed to a
// follow-up queue — "all" joins every handed marker in ONE // live run drain from pi's STEERING queue — the lesson loop is one
// continuation turn, matching the idle wake's carry-everything // continuous teach→quiz→teach run, so delivery must inject at turn
// semantics (the default "one-at-a-time" would spend one teacher // boundaries INSIDE the run, not wait for it to stop (follow-ups).
// turn per marker). // "all" joins every handed marker at one boundary, matching the idle
followUpMode: "all", // wake's carry-everything semantics (the default "one-at-a-time"
// would spend one teacher turn per marker).
steeringMode: "all",
// Wave B (owner-approved 2026-09-26): the model-facing conversion is // Wave B (owner-approved 2026-09-26): the model-facing conversion is
// THE app seam — markers flatten to their canonical user text // THE app seam — markers flatten to their canonical user text
// (verdictText / HANDOFF_TEXT / steer text, all single-sourced), // (verdictText / HANDOFF_TEXT / steer text, all single-sourced),
+9 -5
View File
@@ -315,6 +315,15 @@ export async function handleChatInput(
// is just the FIRST subscriber (a view). A client disconnect can no // is just the FIRST subscriber (a view). A client disconnect can no
// longer abort the turn; the remote kill is the abort channel, and // longer abort the turn; the remote kill is the abort channel, and
// late views reattach via GET /api/chats/[id]/stream. // late views reattach via GET /api/chats/[id]/stream.
// Wave C (owner correction, 2026-09-26 — steering): register the live
// agent BEFORE runPiTurn — the loop's start steering-poll drains the
// queue SYNCHRONOUSLY inside that call, so a later registration would
// miss it. Registration also steers any pending registry markers into
// this run: a marker parked behind a just-answered quiz rides THIS
// run's first LLM call (the native replacement for continuation
// coalescing). Settlement still cannot beat the flow below: it needs
// a streamed response, which never happens inside the constructor.
registerLiveAgent(chatId, constructed);
const handle = runPiTurn({ const handle = runPiTurn({
agent: constructed, agent: constructed,
// Stream-level failures are provider/transport internals — masked, // Stream-level failures are provider/transport internals — masked,
@@ -373,11 +382,6 @@ export async function handleChatInput(
// runPiTurn call is race-free: the agent loop only ticks in later // runPiTurn call is race-free: the agent loop only ticks in later
// microtasks, so settle (and thus unregister) cannot beat us here. // microtasks, so settle (and thus unregister) cannot beat us here.
registerTurn(chatId, handle); registerTurn(chatId, handle);
// Wave C: the pump hands queued steer markers to THIS run's agent
// (pi's own follow-up queue) - same race-freedom as registerTurn:
// the loop only ticks in later microtasks, so settlement cannot
// beat the registration.
registerLiveAgent(chatId, constructed);
// The POST response: just this caller's view onto the running turn — // The POST response: just this caller's view onto the running turn —
// identical wire dialect to the reattach stream. // identical wire dialect to the reattach stream.
return piTurnResponse(handle); return piTurnResponse(handle);
+60 -40
View File
@@ -8,18 +8,26 @@
* The steer joins a per-chat serialized registry and the pump drives * The steer joins a per-chat serialized registry and the pump drives
* it along ONE of two paths: * it along ONE of two paths:
* *
* 1. LIVE RUN → pi follow-up: a turn is streaming, so the marker is * 1. LIVE RUN → pi steering: the marker is handed to the LIVE
* handed to the LIVE agent's followUp queue (agent.followUp). * agent's steering queue (agent.steer). pi polls that queue at
* pi polls that queue when the run would otherwise stop and * TURN BOUNDARIES — after a completed assistant message AND its
* CONTINUES THE SAME RUN — the marker joins the context, rides * tool results (never between a toolCall and its result), and
* the live event stream (message_start/message_end), and lands * again at the run's start (a continuation drains the queue
* in the record at that run's persist. The question stopper * BEFORE its first LLM call) — and CONTINUES THE SAME RUN: the
* runs BEFORE the follow-up poll and exits the loop, so an open * marker joins the context mid-lesson, rides the live event
* quiz is never overridden: a handed marker strands (see * stream (message_start/message_end), and lands in the record
* settleSteers) and waits for the next run — today's pacing. * at that run's persist. This is the owner's 2026-09-26
* probe-handoff markers NEVER take this path (owner ruling: * correction: a real lesson is one continuous teach→quiz→teach
* the handoff must start the TEACHER's run — a record-level * loop, so delivery must happen BETWEEN messages inside the
* branch only a fresh wake can make). * run — waiting for the run to stop (follow-ups) only ever
* fires at a stopper exit, which is exactly when it must not.
* The question stopper runs BEFORE the post-turn poll and
* exits the loop, so a quiz-terminated run never injects: the
* handed marker strands (see settleSteers) and waits — the
* ANSWER run then picks it up at its start poll. probe-handoff
* markers NEVER take this path (owner ruling: the handoff must
* start the TEACHER's run — a record-level branch only a fresh
* wake can make).
* 2. IDLE WAKE: no live turn. WAIT FOR IDLE (turn-liveness OR a * 2. IDLE WAKE: no live turn. WAIT FOR IDLE (turn-liveness OR a
* dangling question — an unanswered client-side tool call breaks * dangling question — an unanswered client-side tool call breaks
* provider conversion), then fire ONE server-side wake turn * provider conversion), then fire ONE server-side wake turn
@@ -75,10 +83,10 @@ interface QueuedSteer {
steer: SteerMessage; steer: SteerMessage;
resolve: () => void; resolve: () => void;
reject: (reason: Error) => void; reject: (reason: Error) => void;
/** The live agent this marker was handed to via followUp() — set by the /** The live agent this marker was steered into (agent.steer) — set by
* pump's live path. Guards double-handoff (a re-pumped loop must not * the hand-off helper. Guards double-handoff (a re-pumped loop or a
* re-queue what a run already holds) and is CLEARED at settlement for * re-registered run must not re-queue what a run already holds) and is
* markers the dying run never injected. */ * CLEARED at settlement for markers the dying run never injected. */
handedTo?: Agent; handedTo?: Agent;
} }
@@ -174,15 +182,34 @@ export function deliverJobResult(steer: SteerMessage): Promise<void> {
}); });
} }
/** The chat-handler registers the CURRENTLY STREAMING turn's agent right /** Hand every eligible pending marker to a live agent's steering queue
* after registerTurn — the pump's live path hands queued markers to this * (idempotent per agent via handedTo). Called from BOTH the pump's live
* agent's pi-native follow-up queue (followUpMode "all": every handed * path and registerLiveAgent — a parked marker (stranded behind a quiz)
* marker joins the continuation turn together, matching the wake's * must not wait for the pump loop to notice the new run: the handler
* carry-everything semantics). Registration is race-free the same way * registers the answer run's agent BEFORE runPiTurn, so this hand-off
* registerTurn is: the agent loop only ticks in later microtasks, so the * beats the loop's synchronous start-poll drain and the marker rides the
* settle seam cannot beat the registration. */ * run's FIRST LLM call (the native equivalent of the old continuation
* coalescing). */
function handOffEligible(chatId: string, agent: Agent): void {
const queue = state().queues.get(chatId);
if (!queue || queue.length === 0) return;
for (const entry of queue) {
if (entry.handedTo !== agent && entry.steer.customType !== "probe-handoff") {
agent.steer(entry.steer);
entry.handedTo = agent;
}
}
}
/** The chat-handler registers the CURRENTLY STREAMING turn's agent BEFORE
* runPiTurn (the loop's start steering-poll drains synchronously inside
* that call — a later registration would miss it). Pending registry
* markers are steered into the run right here. The settle seam cannot
* beat the registration: settlement needs a streamed response, which
* never happens inside the constructor call. */
export function registerLiveAgent(chatId: string, agent: Agent): void { export function registerLiveAgent(chatId: string, agent: Agent): void {
state().liveAgents.set(chatId, agent); state().liveAgents.set(chatId, agent);
handOffEligible(chatId, agent);
} }
/** Clear the live agent at settlement — MUST run before settleSteers so the /** Clear the live agent at settlement — MUST run before settleSteers so the
@@ -351,33 +378,26 @@ async function pump(chatId: string): Promise<void> {
} }
s.queues.set(chatId, stillPending); s.queues.set(chatId, stillPending);
// VEHICLE 1 — THE LIVE RUN: pi's own follow-up queue. The agent of // VEHICLE 1 — THE LIVE RUN: pi's steering queue. The agent of the
// the streaming turn takes every marker not already handed to IT; // streaming turn takes every eligible marker; pi injects at the
// pi polls the queue when the run would otherwise stop, injects // next turn boundary (message_start/message_end on the live wire,
// them (message_start/message_end on the live wire), and continues // between completed assistant+toolResult units) and continues the
// the run. Delivery resolves at settlement (settleSteers). Nothing // run. Delivery resolves at settlement (settleSteers). Nothing else
// else to do in this loop iteration — the turn owns the delivery. // to do in this loop iteration — the turn owns the delivery.
const live = s.liveAgents.get(chatId); const live = s.liveAgents.get(chatId);
if (live && isTurnRunning(chatId)) { if (live && isTurnRunning(chatId)) {
const handable = stillPending.filter( handOffEligible(chatId, live);
(e) => e.handedTo !== live && e.steer.customType !== "probe-handoff", const handable = stillPending.filter((e) => e.handedTo === live);
);
let handedAny = false;
for (const entry of handable) {
live.followUp(entry.steer);
entry.handedTo = live;
handedAny = true;
}
// probe-handoffs never ride a live run (owner ruling: the // probe-handoffs never ride a live run (owner ruling: the
// handoff must START the teacher's run) — if any remain // handoff must START the teacher's run) — if any remain
// pending, wait the run out and loop to the wake below. // pending, wait the run out and loop to the wake below.
const handoffWaits = stillPending.some( const handoffWaits = stillPending.some(
(e) => e.steer.customType === "probe-handoff", (e) => e.steer.customType === "probe-handoff",
); );
if (handedAny && !handoffWaits) { if (handable.length > 0 && !handoffWaits) {
return; // the run delivers; settleSteers re-pumps the rest return; // the run delivers; settleSteers re-pumps the rest
} }
if (handedAny) { if (handable.length > 0) {
await delay(POLL_MS); await delay(POLL_MS);
await waitForIdle(chatId); await waitForIdle(chatId);
continue; continue;
+78
View File
@@ -389,6 +389,84 @@ describe("teacher chat flow (scripted model)", () => {
assert.equal((record.messages[at + 1] as { role?: string } | undefined)?.role, "assistant", "the wake response follows the marker"); assert.equal((record.messages[at + 1] as { role?: string } | undefined)?.role, "assistant", "the wake response follows the marker");
}); });
it("Wave C steering: a mid-run result injects BETWEEN turn units — never splitting a toolCall from its toolResult", { timeout: 15000 }, async () => {
// THE OWNER'S CORRECTION (2026-09-26): a real lesson is ONE continuous
// teach→quiz→teach run, so a subagent result must land BETWEEN messages
// inside the run — pi's steering queue, drained at turn boundaries
// (after a completed assistant message AND its tool results). Waiting
// for run end (follow-ups) can never fire mid-lesson: the question
// stopper pre-empts that poll at exactly the moments a run DOES end.
const chat = createConfiguredChat("Test: mid-run steering");
createdChats.push(chat.id);
// Step 1 (gated): a non-question tool — an unknown name yields an
// error toolResult WITHOUT terminating, so the run continues. Step 2:
// the reaction AFTER the boundary injection (records its prompt).
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const seen: string[] = [];
const faux = createFauxCore({});
faux.setResponses([
async () => {
await gate;
return fauxAssistantMessage(
[fauxToolCall("no_such_tool", { note: "x" }, { id: "call-mid" })],
{ stopReason: "toolUse" },
);
},
(context) => {
seen.push(context.messages.map((m) => JSON.stringify((m as { content: unknown }).content)).join("\n"));
return fauxAssistantMessage([fauxText("Folded the marker in.")]);
},
]);
const engine: PiEngine = { model: faux.getModel(), streamFn: faux.streamSimple };
// A wake here would be a BUG (delivery must ride the live run) — the
// counter keeps an erroneous wake hermetic and assertable.
let wakes = 0;
setTurnRunner(async () => {
wakes++;
return 200;
});
// The turn floats mid-run: step 1 is gated, the run is live.
const res = handleChatRequest(post(chat.id, { type: "text", text: "teach" }), agentFactory(engine));
await waitFor(() => isTurnRunning(chat.id), 8000, "the turn is live");
// The result lands MID-RUN: the pump hands it to the live agent's
// steering queue (vehicle 1) — the queued marker waits for the
// in-flight turn to complete so the injection lands at its boundary.
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-mid" });
const steer = buildSubagentSteer(job, { brief: "Mid-run brief.", sources: [] });
const delivery = deliverJobResult(steer);
await new Promise((r) => setTimeout(r, 300)); // vehicle 1 hands off
assert.equal(wakes, 0, "no wake — the live run owns the delivery");
release(); // step 1 completes → tool result → TURN BOUNDARY → inject → step 2
await drain(await res);
await delivery; // resolves at settleSteers (the run persisted the marker)
// Record shape: the marker sits BETWEEN completed turn units — after
// the toolResult it must never split, adjacent to it.
const record = chatStore.getChat(chat.id)!;
const at = (role: string, pred: (m: LhMessage) => boolean) =>
record.messages.findIndex((m) => (m as { role?: string }).role === role && pred(m as never));
const callAt = record.messages.findIndex(
(m) => (m as { role?: string }).role === "assistant" &&
JSON.stringify((m as { content?: unknown }).content).includes("call-mid"),
);
const resultAt = at("toolResult", (m) => (m as { toolCallId?: string }).toolCallId === "call-mid");
const steerAt = record.messages.findIndex((m) => (m as { id?: string }).id === steer.id);
const finalAt = at("assistant", (m) => JSON.stringify((m as { content?: unknown }).content).includes("Folded the marker in."));
assert.ok(callAt !== -1 && resultAt !== -1 && steerAt !== -1 && finalAt !== -1, `all units present: ${callAt}/${resultAt}/${steerAt}/${finalAt}`);
assert.ok(callAt < resultAt, "the toolCall precedes its toolResult (bonded pair)");
assert.equal(steerAt, resultAt + 1, "the marker injected at the turn boundary — immediately after the completed unit");
assert.ok(steerAt < finalAt, "the run CONTINUED after the injection (same run, no wake)");
assert.equal(record.messages.filter((m) => (m as { id?: string }).id === steer.id).length, 1, "exactly one marker copy");
assert.ok(seen[0]!.includes("Mid-run brief."), "the model SAW the marker in its next prompt");
assert.equal(wakes, 0, "delivery rode the live run end to end");
});
it("a cancelled view NEVER aborts the turn — it runs to completion (owner ruling 2026-09-25)", async () => { it("a cancelled view NEVER aborts the turn — it runs to completion (owner ruling 2026-09-25)", async () => {
let release!: () => void; let release!: () => void;
const gate = new Promise<void>((r) => (release = r)); const gate = new Promise<void>((r) => (release = r));
+51 -4
View File
@@ -854,14 +854,14 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
// ─── continuation coalescing (the delivery-starvation fix) ─────────────────── // ─── continuation coalescing (the delivery-starvation fix) ───────────────────
describe("Wave C: live follow-up handoff + settlement reconciliation", () => { describe("Wave C: live steering handoff + settlement reconciliation", () => {
/** A minimal stand-in for pi's Agent — the pump touches only followUp(). */ /** A minimal stand-in for pi's Agent — the pump touches only steer(). */
function fakeAgent(): { handed: SteerMessage[]; agent: unknown } { function fakeAgent(): { handed: SteerMessage[]; agent: unknown } {
const handed: SteerMessage[] = []; const handed: SteerMessage[] = [];
return { handed, agent: { followUp: (m: SteerMessage) => handed.push(m) } }; return { handed, agent: { steer: (m: SteerMessage) => handed.push(m) } };
} }
it("hands queued markers to the LIVE run (pi followUp) — no wake fires, the registry waits", async () => { it("hands queued markers to the LIVE run (pi steering queue) — no wake fires, the registry waits", async () => {
const { chatId, jobId, steer } = setup(); const { chatId, jobId, steer } = setup();
const { handed, agent } = fakeAgent(); const { handed, agent } = fakeAgent();
let fired = 0; let fired = 0;
@@ -944,6 +944,53 @@ describe("Wave C: live follow-up handoff + settlement reconciliation", () => {
assert.equal(fired[0]![0]!.id, handoff.id, "the handoff rode the wake"); assert.equal(fired[0]![0]!.id, handoff.id, "the handoff rode the wake");
}); });
it("registerLiveAgent immediately steers pending markers into the NEW run (the parked-behind-quiz path)", async () => {
const { chatId, jobId, steer } = setup();
const { handed, agent } = fakeAgent();
let fired = 0;
setTurnRunner(async () => {
fired++;
return 200;
});
// A dangling question has the pump parked in waitForIdle: the
// marker is pending but no live run exists to take it.
chatStore.saveMessages(chatId, [
...nativeToolMessages("teach me", "probe_done", "call-q", { ok: true }),
{
id: "a-q",
role: "assistant",
content: [{ type: "toolCall", id: "quiz-call", name: "quiz", arguments: { question: "q", choices: ["a", "b"] } as never }],
api: "openai-completions",
provider: "openrouter",
model: "test",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
stopReason: "toolUse",
timestamp: 1,
} as never,
]);
const delivery = deliverJobResult(steer);
await delay(300); // let the pump park behind the dangling question
assert.equal(handed.length, 0, "nothing to hand to — no live agent");
// The learner answers: the handler registers the answer run's agent
// BEFORE runPiTurn, so the marker is steered into it synchronously —
// it rides the run's START steering poll (first LLM call).
assert.ok(beginTurn(chatId));
registerLiveAgent(chatId, agent as never);
assert.equal(handed.length, 1, "registration itself steered the marker in");
assert.equal(handed[0]!.id, steer.id);
// The answer run persisted the marker — the parked pump's wake
// re-check resolves it without ever firing a turn.
unregisterLiveAgent(chatId);
settleSteers(chatId, [steer as never]);
endTurn(chatId);
await delivery;
assert.equal(fired, 0, "the parked wake never fired — the run delivered");
assert.equal(jobStore.getJob(jobId)!.status, "done");
});
it("the idle wake carries EVERY pending marker in ONE turn", async () => { it("the idle wake carries EVERY pending marker in ONE turn", async () => {
const { chatId, steer } = setup(); const { chatId, steer } = setup();
const job2 = jobStore.createJob({ kind: "research", chatId, toolCallId: "call-2" }); const job2 = jobStore.createJob({ kind: "research", chatId, toolCallId: "call-2" });