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:
+128
-44
@@ -11,9 +11,10 @@
|
||||
* no-Chrome failure)
|
||||
* 4. the visual-maker loop (fake model + fake headless): render-and-look
|
||||
* success, forced look on premature finish, never-writes failure
|
||||
* 5. job wrappers + spawn-hook kind dispatch (demo part → demo job →
|
||||
* part fulfilled; visual failure path)
|
||||
* 6. tool registry wiring (client-side demo/visual tools, schemas)
|
||||
* 5. job runners + dispatch (demo part → demo job → steer via a fake
|
||||
* deliver; visual failure path; end-to-end dispatch through
|
||||
* handleChatRequest with the ack protocol)
|
||||
* 6. tool registry wiring (server-executed dispatch tools, schemas)
|
||||
*
|
||||
* Cleanup follows tests/jobs.test.ts idioms (created chat/job files removed
|
||||
* in the after() hook; overrides reset in finally blocks).
|
||||
@@ -43,13 +44,35 @@ import { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts";
|
||||
import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts";
|
||||
import { createJobRunner, demoModelOverride, visualModelOverride } from "../src/agent/subagents/runner.ts";
|
||||
import { runVisualMaker } from "../src/agent/subagents/visual-maker.ts";
|
||||
import { isSubagentFailure, type SubagentSteerMessage } from "../src/core/contracts.ts";
|
||||
import { createTeacherTools } from "../src/agent/tools.ts";
|
||||
import { chatStore } from "../src/store/chats.ts";
|
||||
import { jobStore } from "../src/store/jobs.ts";
|
||||
|
||||
/** The runner with the same wired fs ports the chat-handler injects. */
|
||||
const jobRunner = createJobRunner({ chatStore, jobStore });
|
||||
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
||||
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||
|
||||
/**
|
||||
* A fake deliver seam + a runner wired like the chat-handler's, per test:
|
||||
* capture the steer and mimic the pipeline's final status flip so the job
|
||||
* record reads exactly as production does after the wake turn completes.
|
||||
*/
|
||||
function testRunner() {
|
||||
const steers: SubagentSteerMessage[] = [];
|
||||
const runner = createJobRunner({
|
||||
chatStore,
|
||||
jobStore,
|
||||
deliver: async (steer) => {
|
||||
steers.push(steer);
|
||||
const jobId = steer.metadata.subagentResult.jobId;
|
||||
const job = jobStore.getJob(jobId);
|
||||
if (job && job.status === "running") {
|
||||
const failed = job.result ? isSubagentFailure(job.kind, job.result as never) : true;
|
||||
jobStore.updateJob(jobId, { status: failed ? "error" : "done", activity: failed ? "failed" : "delivered" });
|
||||
}
|
||||
},
|
||||
});
|
||||
return { steers, runner };
|
||||
}
|
||||
import { artifactRenderers } from "../components/artifacts/index.ts";
|
||||
|
||||
const JOBS_DIR = path.join(PATHS.data, "jobs");
|
||||
@@ -399,6 +422,37 @@ describe("visual maker (fake model + fake headless)", () => {
|
||||
assert.equal(headless.svgRenders.length, 1, "the gate rendered the svg once itself");
|
||||
});
|
||||
|
||||
it("edit_svg: exact-match single replacement, with fuzzy context on a miss", async () => {
|
||||
const headless = fakeHeadless();
|
||||
const model = scriptedModel([
|
||||
{ toolCalls: [{ toolCallId: "w1", toolName: "write_svg", input: { svg: SVG_1 } }] },
|
||||
{ toolCalls: [{ toolCallId: "r1", toolName: "render_svg", input: {} }] },
|
||||
// A miss: old_text not in the document (error fed back, nothing changed)
|
||||
{ toolCalls: [{ toolCallId: "e1", toolName: "edit_svg", input: { old_text: "NOT-PRESENT", new_text: "x" } }] },
|
||||
// A hit: exactly one occurrence — applied
|
||||
{ toolCalls: [{ toolCallId: "e2", toolName: "edit_svg", input: { old_text: '<line x1="10"', new_text: '<line x1="20"' } }] },
|
||||
{ toolCalls: [{ toolCallId: "r2", toolName: "render_svg", input: {} }] },
|
||||
{ text: "Moved the start of the line." },
|
||||
]);
|
||||
const result = await runVisualMaker({ brief: "b" }, { model, headless });
|
||||
|
||||
assert.equal(result.svg, SVG_1.replace('<line x1="10"', '<line x1="20"'), "the edit was applied to the delivered svg");
|
||||
assert.equal(result.notes, "Moved the start of the line.");
|
||||
assert.equal(headless.svgRenders.length, 2, "re-rendered after the edit (the look gate saw the change)");
|
||||
});
|
||||
|
||||
it("edit_svg before write_svg errors and never edits nothing into a deliverable", async () => {
|
||||
const headless = fakeHeadless();
|
||||
const model = scriptedModel([
|
||||
// Nothing written yet — the edit must error, not crash the loop
|
||||
{ toolCalls: [{ toolCallId: "e0", toolName: "edit_svg", input: { old_text: "svg", new_text: "x" } }] },
|
||||
{ text: "Cannot proceed." },
|
||||
]);
|
||||
const result = await runVisualMaker({ brief: "b" }, { model, headless });
|
||||
assert.equal(result.svg, "", "the failed edit produced no deliverable");
|
||||
assert.match(result.notes ?? "", /never wrote an SVG/);
|
||||
});
|
||||
|
||||
it("feeds render errors back to the model instead of crashing", async () => {
|
||||
const headless = fakeHeadless({ renderFailures: 1 });
|
||||
const model = scriptedModel([
|
||||
@@ -427,7 +481,7 @@ describe("visual maker (fake model + fake headless)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5. job wrappers + spawn dispatch ────────────────────────────────────────
|
||||
// ─── 5. job runners + dispatch ───────────────────────────────────────────────
|
||||
|
||||
const DEMO_INPUT = { title: "Compound interest", brief: "Slider for rate, curve of growth over 30 years." };
|
||||
|
||||
@@ -448,8 +502,9 @@ async function drain(res: Response): Promise<string> {
|
||||
return res.text();
|
||||
}
|
||||
|
||||
|
||||
describe("demo/visual job runners", () => {
|
||||
it("runDemoJob fulfills the part with a tested DemoResult and marks the job done", async () => {
|
||||
it("runJob completes a demo: result on the job, steer via deliver, ack stays on the part", async () => {
|
||||
const chat = chatStore.createChat("Test: demo runner");
|
||||
createdChats.push(chat.id);
|
||||
chatStore.saveMessages(chat.id, [
|
||||
@@ -457,42 +512,52 @@ describe("demo/visual job runners", () => {
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-demo", toolCallId: "call-d1", state: "input-available", input: DEMO_INPUT }],
|
||||
parts: [{ type: "tool-demo", toolCallId: "call-d1", state: "output-available", input: DEMO_INPUT, output: "Dispatched." }],
|
||||
},
|
||||
] as never);
|
||||
|
||||
const job = jobStore.createJob({ kind: "demo", chatId: chat.id, toolCallId: "call-d1" });
|
||||
createdJobs.push(job.id);
|
||||
|
||||
// runDemoJob has no headless injection point — the module override is the seam.
|
||||
// runJob has no headless injection point — the module override is the seam.
|
||||
headlessOverride.current = fakeHeadless();
|
||||
demoModelOverride.current = scriptedModel([
|
||||
{ toolCalls: [{ toolCallId: "w1", toolName: "write_demo", input: { html: GOOD_HTML } }] },
|
||||
{ toolCalls: [{ toolCallId: "r1", toolName: "run_demo", input: {} }] },
|
||||
{ text: "Sweep the rate slider." },
|
||||
]);
|
||||
const { steers, runner } = testRunner();
|
||||
try {
|
||||
await jobRunner.runDemoJob(job.id, {
|
||||
model: scriptedModel([
|
||||
{ toolCalls: [{ toolCallId: "w1", toolName: "write_demo", input: { html: GOOD_HTML } }] },
|
||||
{ toolCalls: [{ toolCallId: "r1", toolName: "run_demo", input: {} }] },
|
||||
{ text: "Sweep the rate slider." },
|
||||
]),
|
||||
});
|
||||
await runner.runJob(job.id);
|
||||
} finally {
|
||||
headlessOverride.current = undefined;
|
||||
demoModelOverride.current = undefined;
|
||||
}
|
||||
|
||||
const done = jobStore.getJob(job.id)!;
|
||||
assert.equal(done.status, "done");
|
||||
assert.equal(done.activity, "run_demo: clean", "final activity line recorded");
|
||||
const result = done.result as { title: string; html: string; tested: boolean };
|
||||
assert.equal(done.activity, "delivered", "the fake deliver flips status like the wake pipeline");
|
||||
const result = done.result as { title: string; html: string; tested: boolean; notes?: string };
|
||||
assert.equal(result.title, "Compound interest");
|
||||
assert.equal(result.html, GOOD_HTML);
|
||||
assert.equal(result.tested, true);
|
||||
|
||||
// The steer carries the artifact for the renderer.
|
||||
assert.equal(steers.length, 1, "exactly one steer");
|
||||
const data = steers[0]!.parts[1];
|
||||
assert.ok(data.type === "data-subagent-artifact");
|
||||
assert.equal(data.data.kind, "demo");
|
||||
assert.deepEqual(data.data.result, result);
|
||||
|
||||
// The tool part keeps the DISPATCH ACK forever — the result never
|
||||
// rewrites it (the teacher gets the result once, via the steer).
|
||||
const saved = chatStore.getChat(chat.id)!;
|
||||
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-demo");
|
||||
assert.ok(part && part.type === "tool-demo", "demo tool part persisted");
|
||||
assert.equal(part.state, "output-available", "part fulfilled");
|
||||
assert.equal((part as { output?: { html?: string } }).output?.html, GOOD_HTML);
|
||||
assert.equal(typeof (part as { output?: unknown }).output, "string", "part output is the ack, not the result");
|
||||
});
|
||||
|
||||
it("runVisualJob fulfills the part even on failure and marks the job error", async () => {
|
||||
it("runJob still steers on visual failure and the job lands error", async () => {
|
||||
const chat = chatStore.createChat("Test: visual runner failure");
|
||||
createdChats.push(chat.id);
|
||||
chatStore.saveMessages(chat.id, [
|
||||
@@ -500,33 +565,38 @@ describe("demo/visual job runners", () => {
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-visual", toolCallId: "call-v1", state: "input-available", input: { brief: "b" } }],
|
||||
parts: [{ type: "tool-visual", toolCallId: "call-v1", state: "output-available", input: { brief: "b" }, output: "Dispatched." }],
|
||||
},
|
||||
] as never);
|
||||
|
||||
const job = jobStore.createJob({ kind: "visual", chatId: chat.id, toolCallId: "call-v1" });
|
||||
createdJobs.push(job.id);
|
||||
|
||||
await jobRunner.runVisualJob(job.id, { model: scriptedModel([{ text: "cannot" }]) });
|
||||
visualModelOverride.current = scriptedModel([{ text: "cannot" }]);
|
||||
const { steers, runner } = testRunner();
|
||||
try {
|
||||
await runner.runJob(job.id);
|
||||
} finally {
|
||||
visualModelOverride.current = undefined;
|
||||
}
|
||||
|
||||
const errored = jobStore.getJob(job.id)!;
|
||||
assert.equal(errored.status, "error");
|
||||
assert.match(errored.error ?? "", /never wrote an SVG/);
|
||||
|
||||
const saved = chatStore.getChat(chat.id)!;
|
||||
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-visual");
|
||||
assert.ok(part && part.type === "tool-visual", "visual tool part persisted");
|
||||
assert.equal(part.state, "output-available", "failure still fulfills the part");
|
||||
const output = (part as { output?: { svg?: string; notes?: string } }).output!;
|
||||
assert.equal(output.svg, "");
|
||||
assert.match(output.notes ?? "", /^FAILED:/);
|
||||
assert.ok(steers[0], "failure still delivers a steer — the teacher learns the outcome");
|
||||
const data = steers[0]!.parts[1];
|
||||
assert.ok(data.type === "data-subagent-artifact");
|
||||
const result = data.data.result as { svg: string; notes?: string };
|
||||
assert.equal(result.svg, "");
|
||||
assert.match(result.notes ?? "", /^FAILED:/);
|
||||
});
|
||||
|
||||
it("the spawn hook dispatches a demo part to the demo runner (kind→runner map)", async () => {
|
||||
it("dispatch through a teacher turn: the call acks mid-turn and the runner completes in the background", async () => {
|
||||
const chat = chatStore.createChat("Test: demo spawn dispatch");
|
||||
createdChats.push(chat.id);
|
||||
|
||||
// A teacher that calls demo on turn 1 (scripted stream, like jobs.test.ts).
|
||||
// A teacher that calls demo on turn 1, then continues in the SAME turn.
|
||||
let turn = 0;
|
||||
const teacher = new MockLanguageModelV4({
|
||||
doStream: async () => {
|
||||
@@ -552,12 +622,15 @@ describe("demo/visual job runners", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// The overrides must be live BEFORE the request — dispatch fires the
|
||||
// runner mid-stream, inside the tool execute.
|
||||
demoModelOverride.current = scriptedModel([
|
||||
{ toolCalls: [{ toolCallId: "w1", toolName: "write_demo", input: { html: GOOD_HTML } }] },
|
||||
{ toolCalls: [{ toolCallId: "r1", toolName: "run_demo", input: {} }] },
|
||||
{ text: "Sweep it." },
|
||||
]);
|
||||
headlessOverride.current = fakeHeadless();
|
||||
const { steers, runner } = testRunner();
|
||||
|
||||
try {
|
||||
await drain(
|
||||
@@ -570,28 +643,38 @@ describe("demo/visual job runners", () => {
|
||||
messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "teach me compounding" }] }],
|
||||
}),
|
||||
}),
|
||||
createTeacherAgent(teacher),
|
||||
createTeacherAgent(
|
||||
teacher,
|
||||
createTeacherTools({
|
||||
chatId: chat.id,
|
||||
chatStore,
|
||||
jobStore,
|
||||
spawn: (job, input) => void runner.runJob(job.id, input),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const job = jobStore.findJobByToolCallId(chat.id, "call-d2");
|
||||
assert.ok(job, "spawn hook created a job for the demo part");
|
||||
assert.ok(job, "dispatch created a job at tool-execute time");
|
||||
assert.equal(job!.kind, "demo");
|
||||
createdJobs.push(job!.id);
|
||||
|
||||
// The background runner (fire-and-forget) completes with the overrides.
|
||||
await waitFor(() => jobStore.getJob(job!.id)!.status !== "running", 5000, "demo job completion");
|
||||
await waitFor(() => jobStore.getJob(job!.id)!.status !== "running", 8000, "demo job completion");
|
||||
const done = jobStore.getJob(job!.id)!;
|
||||
assert.equal(done.status, "done");
|
||||
assert.equal(done.activity, "run_demo: clean");
|
||||
|
||||
const saved = chatStore.getChat(chat.id)!;
|
||||
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-demo");
|
||||
assert.ok(part && part.type === "tool-demo", "demo tool part persisted");
|
||||
assert.equal(part.state, "output-available", "demo part fulfilled by the background runner");
|
||||
assert.equal(part.state, "output-available", "the part resolved with the ack at dispatch time");
|
||||
assert.match(String((part as { output?: unknown }).output), /fire-and-forget/);
|
||||
|
||||
// Idempotency: re-running the spawn hook creates no second job.
|
||||
jobRunner.spawnPendingJobs(chat.id, saved.messages);
|
||||
assert.equal(steers.length, 1, "one job, one steer");
|
||||
assert.equal(steers[0]!.metadata.subagentResult.jobId, job!.id);
|
||||
|
||||
// Idempotency: dispatch dedupes by toolCallId — no second job.
|
||||
assert.equal(jobStore.listJobs(chat.id).length, 1, "no duplicate job for the same tool part");
|
||||
} finally {
|
||||
demoModelOverride.current = undefined;
|
||||
@@ -603,11 +686,12 @@ describe("demo/visual job runners", () => {
|
||||
// ─── 6. registry wiring ──────────────────────────────────────────────────────
|
||||
|
||||
describe("tool + renderer registry wiring", () => {
|
||||
it("demo and visual are CLIENT-SIDE tools with the contract schemas", () => {
|
||||
const tools = jobTools();
|
||||
it("demo and visual are SERVER-EXECUTED dispatch tools with the contract schemas", () => {
|
||||
const { runner } = testRunner();
|
||||
const tools = jobTools({ chatId: "chat_registry", jobStore, spawn: (job) => void runner.runJob(job.id) });
|
||||
assert.deepEqual(Object.keys(tools).sort(), ["demo", "research", "visual"]);
|
||||
for (const name of ["demo", "visual"] as const) {
|
||||
assert.equal(typeof (tools[name] as { execute?: unknown }).execute, "undefined", `${name} has no execute — client-side like quiz/ask`);
|
||||
for (const name of ["demo", "visual", "research"] as const) {
|
||||
assert.equal(typeof (tools[name] as { execute?: unknown }).execute, "function", `${name} executes server-side — fire-and-forget ack per THE ASYNC PROTOCOL`);
|
||||
}
|
||||
assert.equal(demoInputSchema.safeParse({ title: "T", brief: "b" }).success, true);
|
||||
assert.equal(demoInputSchema.safeParse({ brief: "b" }).success, false, "demo requires a title");
|
||||
|
||||
Reference in New Issue
Block a user