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
+19 -14
View File
@@ -1,10 +1,11 @@
import { tool, type ToolSet } from "ai";
import { askInputSchema, quizInputSchema } from "../core/question-core.ts";
import type { ChatStore } from "../core/ports.ts";
import { jobTools } from "./tools/jobs.ts";
import type { JobRecord } from "../core/contracts.ts";
import type { JobStore } from "../core/ports.ts";
import { jobTools, type DemoInput, type ResearchInput, type VisualInput } from "./tools/jobs.ts";
import { planTools, type PlanToolContext } from "./tools/plan.ts";
export { jobTools };
import { planTools } from "./tools/plan.ts";
/**
* The teacher's tools, composed per request by createTeacherTools.
@@ -17,8 +18,10 @@ import { planTools } from "./tools/plan.ts";
* the prompt docs exactly; keep them in sync.
* - planTools (src/agent/tools/plan.ts): SERVER-SIDE, mutate the chat's plan
* tree (needs the chatId closure — hence the per-request factory).
* - jobTools (src/agent/tools/jobs.ts): CLIENT-SIDE semantics, server-side
* fulfillment by background sub-agents (see src/core/contracts.ts).
* - jobTools (src/agent/tools/jobs.ts): SERVER-EXECUTED dispatch — the call
* resolves immediately with a fire-and-forget ack, the background runner
* does the work, and the result arrives later as a steer message
* (contracts.ts "THE ASYNC PROTOCOL").
*/
export const quizTool = tool({
@@ -51,23 +54,25 @@ export const questionTools = {
ask_user_question: askUserQuestionTool,
};
export interface TeacherToolContext {
chatId: string;
/** The storage port plan tools persist through — the composition root
* (chat-handler) injects the wired adapter; the agent layer never
* imports one. */
chatStore: ChatStore;
export interface TeacherToolContext extends PlanToolContext {
/** Job-tool wiring (sub-agent dispatch): the job store + the runner's
* spawn seam. The composition root injects both. */
jobStore: JobStore;
/** Fire the background runner for a freshly created job. The tool input
* rides along — the spawn fires mid-stream, before the tool part is
* persisted to the transcript (a transcript scan would race it). */
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput) => void;
}
/**
* Per-request tool registry. Plan tools close over the ctx (chatId + the
* injected ChatStore) for persistence; question and job tools are
* context-free.
* injected ChatStore) for persistence; question tools are context-free;
* job tools close over the job store + spawn seam.
*/
export function createTeacherTools(ctx: TeacherToolContext): ToolSet {
return {
...questionTools,
...planTools(ctx),
...jobTools(),
...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn }),
};
}