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:
+83
-42
@@ -1,16 +1,29 @@
|
||||
import { tool, type ToolSet } from "ai";
|
||||
import { z } from "zod";
|
||||
import type { JobRecord } from "../../core/contracts.ts";
|
||||
import type { JobStore } from "../../core/ports.ts";
|
||||
|
||||
/**
|
||||
* Async sub-agent job tools (W2: research; W6 adds demo + visual).
|
||||
* CLIENT-SIDE semantics (no execute) — exactly like quiz/ask: the tool call
|
||||
* streams to the browser in `input-available` state and the server turn ends
|
||||
* there. chat-handler's onFinish spawns a background runner that fulfills
|
||||
* the part server-side; the client polls /api/jobs and the continuation
|
||||
* resumes the teacher with the result as a normal tool output.
|
||||
* See src/core/contracts.ts "THE ASYNC PROTOCOL".
|
||||
* Async sub-agent job tools (vision §2.5; W2/W6 kinds + the pi-model
|
||||
* protocol). SERVER-EXECUTED: each call creates the job record, fires the
|
||||
* background runner (via the injected spawn seam — the agent layer never
|
||||
* imports one), and resolves IMMEDIATELY with a fire-and-forget ack. The
|
||||
* teacher's turn does NOT end at the call; the result arrives later as a
|
||||
* steer message that wakes the teacher in a new turn. See contracts.ts
|
||||
* "THE ASYNC PROTOCOL".
|
||||
*/
|
||||
|
||||
/** What the job tools need at execute time (injected by the composition root). */
|
||||
export interface JobToolContext {
|
||||
chatId: string;
|
||||
/** Creates the job record (deduped by toolCallId). */
|
||||
jobStore: JobStore;
|
||||
/** Fire the background runner for a fresh job — fire-and-forget. The
|
||||
* input rides along because the spawn happens MID-STREAM, before
|
||||
* onFinish persists the tool part — a transcript scan would race it. */
|
||||
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput) => void;
|
||||
}
|
||||
|
||||
export const researchInputSchema = z.object({
|
||||
question: z
|
||||
.string()
|
||||
@@ -21,15 +34,6 @@ export const researchInputSchema = z.object({
|
||||
.describe("Why you need this / what the lesson needs it for — focuses the brief. One or two sentences."),
|
||||
});
|
||||
|
||||
export const researchTool = tool({
|
||||
description:
|
||||
"Dispatch a background web-research agent: fact verification, current data, or niche specifics you are not certain about. " +
|
||||
"The call is ASYNC — your turn ENDS here, so say everything you want to say first; the research brief arrives later as " +
|
||||
"this tool's output and the lesson resumes from it. Cite the brief's sources when you use them. " +
|
||||
"NEVER use it for things you know cold, internal reasoning, or math.",
|
||||
inputSchema: researchInputSchema,
|
||||
});
|
||||
|
||||
export const demoInputSchema = z.object({
|
||||
title: z.string().describe("Short title for the demo card, e.g. 'Damped harmonic oscillator'."),
|
||||
brief: z
|
||||
@@ -40,16 +44,6 @@ export const demoInputSchema = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
export const demoTool = tool({
|
||||
description:
|
||||
"Dispatch a background sub-agent that BUILDS and headlessly TESTS an interactive teaching instrument — a single-file HTML " +
|
||||
"demo (parameter sweep, simulation, animation, sampling) rendered live in the lesson. Use it when the concept is DYNAMIC: " +
|
||||
"something to sweep, animate, or sample is better experienced than described. " +
|
||||
"The call is ASYNC — your turn ENDS here, so say everything you want to say first; the tested demo arrives later as this " +
|
||||
"tool's output and the lesson resumes with it. Write a precise brief: concept, controls, interactions, style.",
|
||||
inputSchema: demoInputSchema,
|
||||
});
|
||||
|
||||
export const visualInputSchema = z.object({
|
||||
brief: z
|
||||
.string()
|
||||
@@ -59,26 +53,73 @@ export const visualInputSchema = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
export const visualTool = tool({
|
||||
description:
|
||||
"Dispatch a background sub-agent that hand-authors ONE precise SVG figure, renders it, LOOKS at the render, and iterates " +
|
||||
"until correct. Use it for exact spatial pictures auto-layout diagrams can't do: coordinate geometry, number lines, vectors, " +
|
||||
"function plots, physical layouts. For flows/relations/taxonomies write mermaid inline instead. " +
|
||||
"The call is ASYNC — your turn ENDS here, so say everything you want to say first; the verified figure arrives later as this " +
|
||||
"tool's output and the lesson resumes with it.",
|
||||
inputSchema: visualInputSchema,
|
||||
});
|
||||
/**
|
||||
* The per-call dispatch: create the job (idempotent by toolCallId), fire the
|
||||
* runner, and return the ack. Shared by all three tools — only kind differs.
|
||||
*/
|
||||
function dispatch(
|
||||
ctx: JobToolContext,
|
||||
kind: JobRecord["kind"],
|
||||
toolCallId: string,
|
||||
ack: string,
|
||||
input: ResearchInput | DemoInput | VisualInput,
|
||||
): string {
|
||||
const existing = ctx.jobStore.findJobByToolCallId(ctx.chatId, toolCallId);
|
||||
const job = existing ?? ctx.jobStore.createJob({ kind, chatId: ctx.chatId, toolCallId });
|
||||
if (!existing) ctx.spawn(job, input);
|
||||
return `Dispatched (job ${job.id}). ${ack}`;
|
||||
}
|
||||
|
||||
const RESEARCH_ACK =
|
||||
"This call is fire-and-forget: continue your turn and keep teaching — do not wait, poll, or mention waiting. " +
|
||||
"The researched brief arrives as a new message when ready and the lesson resumes from it; cite its sources when you use them.";
|
||||
const DEMO_ACK =
|
||||
"This call is fire-and-forget: continue your turn and keep teaching — do not wait, poll, or mention waiting. " +
|
||||
"The tested demo arrives as a new message when ready and is rendered in the lesson; teach with it then.";
|
||||
const VISUAL_ACK =
|
||||
"This call is fire-and-forget: continue your turn and keep teaching — do not wait, poll, or mention waiting. " +
|
||||
"The verified figure arrives as a new message when ready and is rendered in the lesson; reference it then.";
|
||||
|
||||
const RESEARCH_DESCRIPTION =
|
||||
"Dispatch a background web-research agent: fact verification, current data, or niche specifics you are not certain about. " +
|
||||
"The call is ASYNC and fire-and-forget — it resolves immediately and your turn continues; the research brief arrives later " +
|
||||
"as a new message and the lesson resumes from it. Cite the brief's sources when you use them. " +
|
||||
"NEVER use it for things you know cold, internal reasoning, or math.";
|
||||
const DEMO_DESCRIPTION =
|
||||
"Dispatch a background sub-agent that BUILDS and headlessly TESTS an interactive teaching instrument — a single-file HTML " +
|
||||
"demo (parameter sweep, simulation, animation, sampling) rendered live in the lesson. Use it when the concept is DYNAMIC: " +
|
||||
"something to sweep, animate, or sample is better experienced than described. " +
|
||||
"The call is ASYNC and fire-and-forget — it resolves immediately and your turn continues; the tested demo arrives later " +
|
||||
"as a new message. Write a precise brief: concept, controls, interactions, style.";
|
||||
const VISUAL_DESCRIPTION =
|
||||
"Dispatch a background sub-agent that hand-authors ONE precise SVG figure, renders it, LOOKS at the render, and iterates " +
|
||||
"until correct. Use it for exact spatial pictures auto-layout diagrams can't do: coordinate geometry, number lines, vectors, " +
|
||||
"function plots, physical layouts. For flows/relations/taxonomies write mermaid inline instead. " +
|
||||
"The call is ASYNC and fire-and-forget — it resolves immediately and your turn continues; the verified figure arrives later " +
|
||||
"as a new message.";
|
||||
|
||||
/**
|
||||
* Per-request job tool registry. Context-free (the runner resolves
|
||||
* everything from the chat record by toolCallId). Takes no dependencies —
|
||||
* part of the dependency-free default registry in teacher-agent.ts.
|
||||
* Per-request job tool registry: each tool closes over the composition root's
|
||||
* context (chatId + jobStore + the spawn seam), so concurrent chats never
|
||||
* share state. chat-handler wires it each turn; tests wire their own.
|
||||
*/
|
||||
export function jobTools(): ToolSet {
|
||||
export function jobTools(ctx: JobToolContext): ToolSet {
|
||||
return {
|
||||
research: researchTool,
|
||||
demo: demoTool,
|
||||
visual: visualTool,
|
||||
research: tool({
|
||||
description: RESEARCH_DESCRIPTION,
|
||||
inputSchema: researchInputSchema,
|
||||
execute: async (input, { toolCallId }) => dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input),
|
||||
}),
|
||||
demo: tool({
|
||||
description: DEMO_DESCRIPTION,
|
||||
inputSchema: demoInputSchema,
|
||||
execute: async (input, { toolCallId }) => dispatch(ctx, "demo", toolCallId, DEMO_ACK, input),
|
||||
}),
|
||||
visual: tool({
|
||||
description: VISUAL_DESCRIPTION,
|
||||
inputSchema: visualInputSchema,
|
||||
execute: async (input, { toolCallId }) => dispatch(ctx, "visual", toolCallId, VISUAL_ACK, input),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+10
-3
@@ -1,6 +1,7 @@
|
||||
import { tool, type ToolSet } from "ai";
|
||||
import { z } from "zod";
|
||||
import type { PlanToolResult, PlanTree } from "../../core/contracts.ts";
|
||||
import type { ChatStore } from "../../core/ports.ts";
|
||||
import {
|
||||
addEdge,
|
||||
addNode,
|
||||
@@ -13,7 +14,13 @@ import {
|
||||
setCurrent,
|
||||
updateNode,
|
||||
} from "../../core/plan-tree.ts";
|
||||
import type { TeacherToolContext } from "../tools.ts";
|
||||
|
||||
/** What the plan tools close over (the plan-tree half of the tool context). */
|
||||
export interface PlanToolContext {
|
||||
chatId: string;
|
||||
/** The storage port plan tools persist through. */
|
||||
chatStore: ChatStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan-tree tools — W1 workstream (docs/plan.md Wave 1; src/core/contracts.ts
|
||||
@@ -48,7 +55,7 @@ const edgeSchema = z.object({
|
||||
* empty) tree, so the model can see what it's working with and fix the call.
|
||||
*/
|
||||
async function runPlanOp(
|
||||
ctx: TeacherToolContext,
|
||||
ctx: PlanToolContext,
|
||||
op: (tree: PlanTree) => PlanTree,
|
||||
): Promise<PlanToolResult> {
|
||||
const { chatId, chatStore } = ctx;
|
||||
@@ -65,7 +72,7 @@ async function runPlanOp(
|
||||
}
|
||||
}
|
||||
|
||||
export function planTools(ctx: TeacherToolContext): ToolSet {
|
||||
export function planTools(ctx: PlanToolContext): ToolSet {
|
||||
return {
|
||||
plan_init: tool({
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user