Pi-style ack-and-steer sub-agent protocol: dispatch mid-turn, steer via server wake turn

The job tools (research/demo/visual) switch from client-side turn-breaking
calls to pi's model: execute resolves IMMEDIATELY with a fire-and-forget
ack, the teacher's turn continues, and the result arrives later as an
injected user-role steer message that wakes the teacher in a new
server-side turn.

- contracts.ts: THE ASYNC PROTOCOL rewritten (ack now, steer later); steer
  types (SubagentSteerMessage, metadata.subagentResult + data-subagent-
  artifact part), pure buildSubagentSteer/steerDelivered/isSubagentFailure
- tools/jobs.ts: jobTools(ctx) factory — server-side execute creates the
  job (deduped by toolCallId), fires spawn with the dispatch-time input
  (the part isn't persisted yet at execute — a transcript scan would race)
- runner.ts: runJob(jobId, input?) builds the steer and hands it to the
  injected deliver seam; pipeline owns the done/error flip (a polling
  client re-syncs once, at the end); failures still steer
- server/jobs.ts (new pipeline): per-chat serialized queues on globalThis,
  idle gates (turn liveness + pending question, 1.5s grace), 409 retry
  with backoff, idempotent per jobId, stale-job recovery sweep (re-deliver
  or re-spawn); wake turns run through the same handleChatRequest
  composition via setTurnRunner injection
- chat-handler.ts: wires runner+deliver+tools ctx; drops spawnTurnJobs
  from onFinish; teacher default registry = question tools only
- plan.ts: PlanToolContext split out (plan tools need no job wiring)
- visual-maker: edit_svg added (pi parity — exact-match single
  replacement, fuzzy context on a miss); researcher: pi's kept/dropped
  source accounting restored
- prompts 20-jobs/40-demos: async discipline rewritten (announce and keep
  teaching, no waiting/polling/fabricating, parallel dispatch, teach with
  the artifact when it arrives)
- tests: jobs.test.ts rewritten (dispatch ack mid-turn, runner steers,
  wake pipeline: deferral/409/idempotency/failure), demos.test.tsx runner
  tests ported to runJob + fake deliver; 222 green

FLAGGED surgical touches outside the stream's owned files, for master
review at merge: src/core/contracts.ts (protocol doc + steer types),
src/core/ui-message.ts (job tool + data-part types), src/server/
chat-handler.ts (wiring), src/server/turn-service.ts (spawnTurnJobs
retirement), src/agent/teacher-agent.ts (default tools), src/agent/tools/
plan.ts (context type split).

Verified live on PORT=3003 with real models: teacher dispatched research
mid-turn, researcher ran real searches/fetches, steer injected on quiz
answer, wake turn wove the brief in citing its sources, job flipped done;
a client/wake race correctly 409'd.
This commit is contained in:
Amos
2026-09-01 19:15:43 +03:00
parent cf3225fb23
commit 99fa3f0847
16 changed files with 1382 additions and 429 deletions
+136 -26
View File
@@ -282,39 +282,56 @@ export interface JobRecord {
activity: string;
createdAt: string;
updatedAt: string;
/** The tool output payload when done (ResearchResult | DemoResult | VisualResult). */
/** The sub-agent's result (steer-artifact payload). */
result?: unknown;
error?: string;
}
/**
* THE ASYNC PROTOCOL — "client-side-tool semantics, server-side fulfillment":
* THE ASYNC PROTOCOL — "ack now, steer later" (the pi sub-agents model,
* vision §2.5):
*
* 1. Job tools (research / demo / visual) are CLIENT-SIDE tools (no execute),
* exactly like quiz/ask. The model calls one; the server turn ends with the
* tool part in `input-available` state. (The teacher is prompted to say its
* piece BEFORE calling, since its turn ends at the call.)
* 2. chat-handler's onFinish (the integration point) scans the new assistant
* message for job tool parts and spawns one background runner per part
* (src/agent/subagents/…, W2/W6). The job store (src/store/jobs.ts) is
* keyed by toolCallId.
* 3. On completion the runner REWRITES the part in the stored chat record to
* `output-available` with the job result as output (read-modify-write of
* the LATEST record — never a stale copy).
* 4. The client polls GET /api/jobs?chatId=… while any job part is pending;
* when a part flips, it re-syncs messages from the server
* (ChatActionsContext.syncFromServer) and the existing
* `sendAutomaticallyWhen` continuation fires — the teacher receives the
* result as a normal tool output and resumes. No new stream machinery.
* 1. Job tools (research / demo / visual) are SERVER-EXECUTED. The model
* calls one MID-TURN; execute creates the job record, fires the
* background runner, and resolves IMMEDIATELY with an acknowledgement
* ("dispatched — continue your turn"). The teacher's turn does NOT end
* at the call: it keeps teaching, may dispatch other independent jobs
* (full parallelism), and ends whenever it ends. The tool part's output
* is the ack, forever.
*
* 5. The composer gate (findPendingToolCall) covers ONLY quiz/ask — but the
* protocol still forbids sending a user message while a job part is
* pending (an unanswered tool call in the outbound list breaks provider
* conversion). So job parts ALSO gate SENDING, with a distinct affordance
* ("waiting for <job>…" + live activity) — the learner can keep reading,
* scrolling, and typing notes in the field; the send unlocks on arrival.
* True mid-job interjection (concurrent turns) is a documented later
* upgrade, not v1.
* 2. The background runner (src/agent/subagents/runner.ts) runs the kind's
* sub-agent loop — isolated context, modelFor(role), the
* verify-never-one-shot doctrine (headless-tested demos, looked-at SVGs,
* sourced briefs) — tracking a live activity line in the job record.
* The client polls GET /api/jobs?chatId=… and renders the activity card.
*
* 3. On completion the runner builds a STEER MESSAGE (buildSubagentSteer)
* and hands it to the wake pipeline (src/server/jobs.ts): a user-role
* UIMessage carrying the result — a text part with what the MODEL needs
* (the research brief, the builder's notes), plus a data-subagent-artifact
* part with what the UI renders (the full result: brief + sources /
* SVG / demo HTML). Provider conversion drops data parts, so artifact
* payloads never enter the teacher's context. metadata.subagentResult
* identifies the job; the UI renders these as sub-agent result cards,
* NEVER as learner interjections.
*
* 4. The wake pipeline delivers the steer by firing a SERVER-SIDE teacher
* turn through the same turn composition as a client request
* (handleChatRequest) — write-ahead persist, turn liveness, extraction
* trigger: one code path. A per-chat serialized queue: a steer waits
* while a turn is in flight OR a question tool part is pending (an
* unanswered client-side tool call breaks provider conversion — the
* steer fires right after the learner's continuation), then fires; a 409
* (a client turn raced in) retries. Delivery is idempotent per jobId, so
* a crashed wake can be re-entered by the stale-job recovery sweep. The
* job's status flips to done/error only AFTER the wake turn completes —
* a polling client that sees the flip re-syncs once and gets the
* finished record.
*
* 5. No pending job parts ever exist, so the composer never gates on jobs
* (the learner can interject mid-job; the steer queues behind their
* turn) and job tool outputs never trigger the client-side
* sendAutomatically continuation — that mechanism stays quiz/ask-only.
*/
export interface ResearchToolInput {
@@ -354,6 +371,99 @@ export interface VisualResult {
notes?: string;
}
// ─── Sub-agent steer messages (THE ASYNC PROTOCOL §3–4) ─────────────────────
/** Message metadata marking a sub-agent steer (contracts.ts §3). */
export interface SubagentResultMeta {
jobId: string;
kind: JobKind;
toolCallId: string;
}
/** The data-subagent-artifact part payload: what the UI renders. */
export interface SubagentArtifactData {
kind: JobKind;
result: ResearchResult | DemoResult | VisualResult;
}
/**
* The steer message: user-role on the wire (UIMessage roles are fixed), but
* NEVER a learner interjection — metadata.subagentResult is the marker the
* UI branches on, and the artifact rides the data part (stripped by provider
* conversion, so the model sees only the text part).
*/
export interface SubagentSteerMessage extends UIMessage {
role: "user";
metadata: { subagentResult: SubagentResultMeta };
parts: [
{ type: "text"; text: string },
{ type: "data-subagent-artifact"; data: SubagentArtifactData },
];
}
/** Whether a result is the failure shape (html/svg empty, failure brief). */
export function isSubagentFailure(kind: JobKind, result: ResearchResult | DemoResult | VisualResult): boolean {
if (kind === "research") {
const r = result as ResearchResult;
return r.sources.length === 0 && /^Research failed:/i.test(r.brief);
}
if (kind === "demo") {
const r = result as DemoResult;
return typeof r.html !== "string" || r.html.length === 0;
}
const r = result as VisualResult;
return typeof r.svg !== "string" || r.svg.length === 0;
}
/** The model-facing text of a steer: what the result IS, what to do with it. */
function steerText(kind: JobKind, result: ResearchResult | DemoResult | VisualResult): string {
if (isSubagentFailure(kind, result)) {
if (kind === "research")
return `${(result as ResearchResult).brief}\n\nContinue with what you can establish and flag the uncertainty to the learner.`;
if (kind === "demo")
return `The demo "${(result as DemoResult).title}" could not be built: ${
(result as DemoResult).notes ?? "unknown reason"
}. Fall back to explaining in prose; if the brief was the problem, re-call demo with a tighter one.`;
return `The figure could not be made: ${(result as VisualResult).notes ?? "unknown reason"}. Fall back to prose or mermaid, or re-call visual with a tighter brief.`;
}
if (kind === "research")
return `Research complete — the researcher's full brief follows. Weave it into the lesson and cite its sources when you use its findings:\n\n${(result as ResearchResult).brief}`;
if (kind === "demo")
return `The demo "${(result as DemoResult).title}" is built, headlessly tested, and rendered in the lesson. Builder notes: ${
(result as DemoResult).notes ?? "(none)"
}.\nTeach with it — reference its controls by name and point the learner at what to try.`;
return `The figure is drawn, verified by inspection, and rendered in the lesson. Maker notes: ${
(result as VisualResult).notes ?? "(none)"
}.\nReference its elements as you teach.`;
}
/**
* Build the steer message for a finished job (pure — shared by the runner,
* the wake pipeline, and tests). Deterministic id per job makes re-delivery
* idempotent.
*/
export function buildSubagentSteer(
job: Pick<JobRecord, "id" | "kind" | "toolCallId">,
result: ResearchResult | DemoResult | VisualResult,
): SubagentSteerMessage {
return {
id: `steer_${job.id}`,
role: "user",
metadata: { subagentResult: { jobId: job.id, kind: job.kind, toolCallId: job.toolCallId } },
parts: [
{ type: "text", text: steerText(job.kind, result) },
{ type: "data-subagent-artifact", data: { kind: job.kind, result } },
],
};
}
/** True when the transcript already carries this job's steer (idempotent delivery). */
export function steerDelivered(messages: UIMessage[], jobId: string): boolean {
return messages.some(
(m) => (m.metadata as { subagentResult?: { jobId?: string } } | undefined)?.subagentResult?.jobId === jobId,
);
}
// ─── Demo iframe bridge (W6) ────────────────────────────────────────────────
// postMessage protocol between the sandboxed demo iframe and its parent card.
// The bridge script is auto-injected into every demo HTML by the demo-card