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:
@@ -165,12 +165,14 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
|
||||
},
|
||||
streamFn: engine.streamFn,
|
||||
shouldStopAfterTurn: stopper,
|
||||
// Wave C (2026-09-26): markers handed to a live run drain from pi's own
|
||||
// follow-up queue — "all" joins every handed marker in ONE
|
||||
// continuation turn, matching the idle wake's carry-everything
|
||||
// semantics (the default "one-at-a-time" would spend one teacher
|
||||
// turn per marker).
|
||||
followUpMode: "all",
|
||||
// Wave C (2026-09-26, owner correction same day): markers handed to a
|
||||
// live run drain from pi's STEERING queue — the lesson loop is one
|
||||
// continuous teach→quiz→teach run, so delivery must inject at turn
|
||||
// boundaries INSIDE the run, not wait for it to stop (follow-ups).
|
||||
// "all" joins every handed marker at one boundary, matching the idle
|
||||
// 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
|
||||
// THE app seam — markers flatten to their canonical user text
|
||||
// (verdictText / HANDOFF_TEXT / steer text, all single-sourced),
|
||||
|
||||
@@ -315,6 +315,15 @@ export async function handleChatInput(
|
||||
// is just the FIRST subscriber (a view). A client disconnect can no
|
||||
// longer abort the turn; the remote kill is the abort channel, and
|
||||
// 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({
|
||||
agent: constructed,
|
||||
// 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
|
||||
// microtasks, so settle (and thus unregister) cannot beat us here.
|
||||
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 —
|
||||
// identical wire dialect to the reattach stream.
|
||||
return piTurnResponse(handle);
|
||||
|
||||
+60
-40
@@ -8,18 +8,26 @@
|
||||
* The steer joins a per-chat serialized registry and the pump drives
|
||||
* it along ONE of two paths:
|
||||
*
|
||||
* 1. LIVE RUN → pi follow-up: a turn is streaming, so the marker is
|
||||
* handed to the LIVE agent's followUp queue (agent.followUp).
|
||||
* pi polls that queue when the run would otherwise stop and
|
||||
* CONTINUES THE SAME RUN — the marker joins the context, rides
|
||||
* the live event stream (message_start/message_end), and lands
|
||||
* in the record at that run's persist. The question stopper
|
||||
* runs BEFORE the follow-up poll and exits the loop, so an open
|
||||
* quiz is never overridden: a handed marker strands (see
|
||||
* settleSteers) and waits for the next run — today's pacing.
|
||||
* 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).
|
||||
* 1. LIVE RUN → pi steering: the marker is handed to the LIVE
|
||||
* agent's steering queue (agent.steer). pi polls that queue at
|
||||
* TURN BOUNDARIES — after a completed assistant message AND its
|
||||
* tool results (never between a toolCall and its result), and
|
||||
* again at the run's start (a continuation drains the queue
|
||||
* BEFORE its first LLM call) — and CONTINUES THE SAME RUN: the
|
||||
* marker joins the context mid-lesson, rides the live event
|
||||
* stream (message_start/message_end), and lands in the record
|
||||
* at that run's persist. This is the owner's 2026-09-26
|
||||
* correction: a real lesson is one continuous teach→quiz→teach
|
||||
* loop, so delivery must happen BETWEEN messages inside the
|
||||
* 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
|
||||
* dangling question — an unanswered client-side tool call breaks
|
||||
* provider conversion), then fire ONE server-side wake turn
|
||||
@@ -75,10 +83,10 @@ interface QueuedSteer {
|
||||
steer: SteerMessage;
|
||||
resolve: () => void;
|
||||
reject: (reason: Error) => void;
|
||||
/** The live agent this marker was handed to via followUp() — set by the
|
||||
* pump's live path. Guards double-handoff (a re-pumped loop must not
|
||||
* re-queue what a run already holds) and is CLEARED at settlement for
|
||||
* markers the dying run never injected. */
|
||||
/** The live agent this marker was steered into (agent.steer) — set by
|
||||
* the hand-off helper. Guards double-handoff (a re-pumped loop or a
|
||||
* re-registered run must not re-queue what a run already holds) and is
|
||||
* CLEARED at settlement for markers the dying run never injected. */
|
||||
handedTo?: Agent;
|
||||
}
|
||||
|
||||
@@ -174,15 +182,34 @@ export function deliverJobResult(steer: SteerMessage): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** The chat-handler registers the CURRENTLY STREAMING turn's agent right
|
||||
* after registerTurn — the pump's live path hands queued markers to this
|
||||
* agent's pi-native follow-up queue (followUpMode "all": every handed
|
||||
* marker joins the continuation turn together, matching the wake's
|
||||
* carry-everything semantics). Registration is race-free the same way
|
||||
* registerTurn is: the agent loop only ticks in later microtasks, so the
|
||||
* settle seam cannot beat the registration. */
|
||||
/** Hand every eligible pending marker to a live agent's steering queue
|
||||
* (idempotent per agent via handedTo). Called from BOTH the pump's live
|
||||
* path and registerLiveAgent — a parked marker (stranded behind a quiz)
|
||||
* must not wait for the pump loop to notice the new run: the handler
|
||||
* registers the answer run's agent BEFORE runPiTurn, so this hand-off
|
||||
* beats the loop's synchronous start-poll drain and the marker rides the
|
||||
* 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 {
|
||||
state().liveAgents.set(chatId, agent);
|
||||
handOffEligible(chatId, agent);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
// VEHICLE 1 — THE LIVE RUN: pi's own follow-up queue. The agent of
|
||||
// the streaming turn takes every marker not already handed to IT;
|
||||
// pi polls the queue when the run would otherwise stop, injects
|
||||
// them (message_start/message_end on the live wire), and continues
|
||||
// the run. Delivery resolves at settlement (settleSteers). Nothing
|
||||
// else to do in this loop iteration — the turn owns the delivery.
|
||||
// VEHICLE 1 — THE LIVE RUN: pi's steering queue. The agent of the
|
||||
// streaming turn takes every eligible marker; pi injects at the
|
||||
// next turn boundary (message_start/message_end on the live wire,
|
||||
// between completed assistant+toolResult units) and continues the
|
||||
// run. Delivery resolves at settlement (settleSteers). Nothing else
|
||||
// to do in this loop iteration — the turn owns the delivery.
|
||||
const live = s.liveAgents.get(chatId);
|
||||
if (live && isTurnRunning(chatId)) {
|
||||
const handable = stillPending.filter(
|
||||
(e) => e.handedTo !== live && e.steer.customType !== "probe-handoff",
|
||||
);
|
||||
let handedAny = false;
|
||||
for (const entry of handable) {
|
||||
live.followUp(entry.steer);
|
||||
entry.handedTo = live;
|
||||
handedAny = true;
|
||||
}
|
||||
handOffEligible(chatId, live);
|
||||
const handable = stillPending.filter((e) => e.handedTo === live);
|
||||
// probe-handoffs never ride a live run (owner ruling: the
|
||||
// handoff must START the teacher's run) — if any remain
|
||||
// pending, wait the run out and loop to the wake below.
|
||||
const handoffWaits = stillPending.some(
|
||||
(e) => e.steer.customType === "probe-handoff",
|
||||
);
|
||||
if (handedAny && !handoffWaits) {
|
||||
if (handable.length > 0 && !handoffWaits) {
|
||||
return; // the run delivers; settleSteers re-pumps the rest
|
||||
}
|
||||
if (handedAny) {
|
||||
if (handable.length > 0) {
|
||||
await delay(POLL_MS);
|
||||
await waitForIdle(chatId);
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user