Swap the server engine to pi-ai/pi-agent-core (Band B+C of docs/pi-engine-swap.md)

The Vercel AI SDK survives only as Band D (useChat client + UIMessage
record types); @openrouter/ai-sdk-provider is uninstalled.

- src/agent/models.ts: PiEngine {model, streamFn} via createModels +
  openrouterProvider subpath; thinkingLevelFor maps config effort none->off
- src/agent/lh-tool.ts: neutral LhTool (zod authoring, optional execute,
  clientQuestion flag); toPiTool converts via z.toJSONSchema and re-runs
  zod inside execute so refinements ride the error-result retry path
- src/server/pi/record-adapter.ts: UIMessage records -> pi Messages
  (dangling tool calls get synthesized interrupted results)
- src/server/pi/ui-stream.ts: SSE bridge emitting AI-SDK-shaped chunks so
  the useChat client parses unchanged (continuation id rule, question
  tools never emit output, tool-input-error for bad authoring)
- teacher-agent.ts: stateless per-turn pi Agents; shouldStopAfterTurn
  stops only when every question-tool call resolved cleanly (errors retry)
- chat-handler.ts: buildAgent(outgoing) factory seam (stateless doctrine);
  assertValidUiMessages owns the retired SDK validation gate
- sub-agents via shared runSubagent (pi-run.ts); extraction stages stop
  after a clean submit; visual-maker keeps its hand-rolled loop over
  direct streamSimple calls; aborted model calls bill no usage
- tests: all model seams ported to pi-ai faux provider (scripted
  FauxResponseSteps, agent factories); handleChatRequest second param
  is now an agent factory

Gates: tsc, lint, build, 305/305. Live GLM smoke (disposable driver,
deleted): prober streaming + persistence, question seam, turn-2
continuation same message id, per-turn real token usage - ALL PASS.
Data wiped at the pivot (chats/graph/jobs, .gitkeeps kept).
This commit is contained in:
amosblomqvist
2026-09-12 02:34:18 +03:00
parent 377d4b6548
commit 2d717fd932
27 changed files with 3557 additions and 769 deletions
+23 -22
View File
@@ -1,7 +1,9 @@
import { ToolLoopAgent, stepCountIs, tool, type LanguageModel, type ToolSet } from "ai";
import { z } from "zod";
import { modelFor } from "../models.ts";
import type { DemoResult, UsageStepLike } from "../../core/contracts.ts";
import type { Usage } from "@earendil-works/pi-ai";
import { lhTool, toPiTools, type LhTool } from "../lh-tool.ts";
import type { PiEngine } from "../models.ts";
import { runSubagent } from "./pi-run.ts";
import type { DemoResult } from "../../core/contracts.ts";
import { getHeadless, type DemoRunResult, type HeadlessApi } from "./headless.ts";
/**
@@ -11,7 +13,7 @@ import { getHeadless, type DemoRunResult, type HeadlessApi } from "./headless.ts
* fix → re-run until clean — before anything reaches the lesson.
*
* Shape: a ToolLoopAgent with two executable tools (write_demo / run_demo) —
* the model is injectable (tests use MockLanguageModelV4) and the headless
* the engine is injectable (tests use a scripted faux engine) and the headless
* API is injectable (tests use a fake; production uses system Chrome).
*
* Failure semantics (decided in the W6 brief): `tested: true` means "the
@@ -26,7 +28,8 @@ export interface DemoBuilderInput {
}
export interface DemoBuilderOptions {
model?: LanguageModel;
/** Test seam: scripted engine; production uses the demoBuilder role. */
engine?: PiEngine;
headless?: HeadlessApi;
/** Max agent steps (each write/run/fix is one step). Default 24 ≈ 6 cycles. */
maxSteps?: number;
@@ -35,7 +38,7 @@ export interface DemoBuilderOptions {
/** Kills the run mid-model-call (2026-09 real-kill abort semantics). */
abortSignal?: AbortSignal;
/** Money channel: one call per agent step, folded into JobRecord.usage. */
onUsage?: (usage: UsageStepLike) => void;
onUsage?: (usage: Usage) => void;
}
const INSTRUCTIONS = `You are a demo builder — a specialist sub-agent inside a learning application. You
@@ -78,13 +81,14 @@ export function createDemoBuilderTools(
state: BuilderState,
headless: HeadlessApi,
onActivity?: (line: string) => void,
): ToolSet {
): Record<string, LhTool<any, any>> {
return {
write_demo: tool({
write_demo: lhTool({
name: "write_demo",
description:
"Write the complete self-contained HTML document for the demo (overwrites any previous version). " +
"Always follow with run_demo to test it headlessly.",
inputSchema: z.object({
schema: z.object({
html: z.string().describe("The complete HTML document: <!doctype html>…</html>, all JS inline."),
}),
execute: async ({ html }) => {
@@ -94,11 +98,12 @@ export function createDemoBuilderTools(
return `Saved (${html.length} bytes). Now call run_demo to test it — never deliver untested.`;
},
}),
run_demo: tool({
run_demo: lhTool({
name: "run_demo",
description:
"Headlessly execute the current demo in Chrome and report console errors, uncaught errors, and failed requests. " +
"Optionally pass a probe expression to assert on page state.",
inputSchema: z.object({
schema: z.object({
probe: z
.string()
.optional()
@@ -174,12 +179,6 @@ export async function runDemoBuilder(input: DemoBuilderInput, opts: DemoBuilderO
}
const state: BuilderState = { html: null, testedHtml: null, lastResult: null };
const agent = new ToolLoopAgent({
model: opts.model ?? modelFor("demoBuilder"),
instructions: INSTRUCTIONS,
tools: createDemoBuilderTools(state, headless, opts.onActivity),
stopWhen: stepCountIs(opts.maxSteps ?? 24),
});
const prompt = [
`Demo title: ${title}`,
@@ -191,13 +190,15 @@ export async function runDemoBuilder(input: DemoBuilderInput, opts: DemoBuilderO
let finalText = "";
try {
const result = await agent.generate({
const result = await runSubagent(toPiTools(createDemoBuilderTools(state, headless, opts.onActivity)), {
role: "demoBuilder",
engine: opts.engine,
systemPrompt: INSTRUCTIONS,
prompt,
maxTurns: opts.maxSteps ?? 24,
abortSignal: opts.abortSignal,
onStepFinish: ({ usage }) => opts.onUsage?.(usage),
onToolExecutionStart: ({ toolCall }) => {
opts.onActivity?.(toolCall.toolName);
},
onUsage: (usage) => opts.onUsage?.(usage),
onToolStart: (toolName) => opts.onActivity?.(toolName),
});
finalText = result.text;
} catch (err) {
+114 -75
View File
@@ -1,6 +1,9 @@
import { ToolLoopAgent, stepCountIs, tool, type LanguageModel, type UIMessage } from "ai";
import type { UIMessage } from "ai";
import type { Usage } from "@earendil-works/pi-ai";
import { z } from "zod";
import { modelFor } from "../models.ts";
import { lhTool, toPiTools } from "../lh-tool.ts";
import type { PiEngine } from "../models.ts";
import { runSubagent } from "./pi-run.ts";
import type {
AuditResult,
CallUsage,
@@ -207,19 +210,50 @@ transcript). The auditor rejects anything unbacked. When in doubt, report; the a
Your entire deliverable is ONE submit_findings call.`;
function createExtractor(model: LanguageModel) {
return new ToolLoopAgent({
model,
instructions: EXTRACTOR_INSTRUCTIONS,
tools: {
submit_findings: tool({
/** One extractor run: the whole deliverable is ONE zod-validated submit
* call (a pi agent purely so the model can retry a schema-invalid call).
* Returns the submitted findings — captured from the execute seam, which
* only fires on a schema-valid call. */
async function runExtractor(
engine: PiEngine | undefined,
prompt: string,
signal: AbortSignal | undefined,
onStep: (usage: Usage, model: string, step: number, finishReason: string, tools: string) => void,
): Promise<SessionFindings> {
let submitted: SessionFindings | undefined;
const result = await runSubagent(
toPiTools({
submit_findings: lhTool({
name: "submit_findings",
description: "Submit the session findings.",
inputSchema: findingsSchema,
execute: async (findings) => findings,
schema: findingsSchema,
execute: async (findings) => {
submitted = findings;
return findings;
},
}),
}),
{
role: "extractor",
engine,
systemPrompt: EXTRACTOR_INSTRUCTIONS,
prompt,
maxTurns: 4,
abortSignal: signal,
onAssistantMessage: (message, step) =>
onStep(
message.usage,
message.model,
step,
message.stopReason,
message.content.filter((c) => c.type === "toolCall").map((c) => (c as { name: string }).name).join(", ") || "(none)",
),
shouldStop: (_message, toolResults) => toolResults.some((r) => r.toolName === "submit_findings" && !r.isError),
},
stopWhen: stepCountIs(4),
});
);
void result;
if (!submitted) throw new Error("the extractor never submitted findings");
return submitted;
}
// ─── the auditor ───────────────────────────────────────────────────────────
@@ -290,19 +324,47 @@ function graphView(nodes: GraphNode[]): string {
return lines.join("\n");
}
function createAuditor(model: LanguageModel) {
return new ToolLoopAgent({
model,
instructions: AUDITOR_INSTRUCTIONS,
tools: {
submit_review: tool({
/** One auditor run — same single-submit shape as the extractor. */
async function runAuditor(
engine: PiEngine | undefined,
prompt: string,
signal: AbortSignal | undefined,
onStep: (usage: Usage, model: string, step: number, finishReason: string, tools: string) => void,
): Promise<AuditResult> {
let submitted: AuditResult | undefined;
const result = await runSubagent(
toPiTools({
submit_review: lhTool({
name: "submit_review",
description: "Submit the verified findings subset (with identity verdicts) + the dropped list.",
inputSchema: auditSchema,
execute: async (review) => review,
schema: auditSchema,
execute: async (review) => {
submitted = review;
return review;
},
}),
}),
{
role: "auditor",
engine,
systemPrompt: AUDITOR_INSTRUCTIONS,
prompt,
maxTurns: 4,
abortSignal: signal,
onAssistantMessage: (message, step) =>
onStep(
message.usage,
message.model,
step,
message.stopReason,
message.content.filter((c) => c.type === "toolCall").map((c) => (c as { name: string }).name).join(", ") || "(none)",
),
shouldStop: (_message, toolResults) => toolResults.some((r) => r.toolName === "submit_review" && !r.isError),
},
stopWhen: stepCountIs(4),
});
);
void result;
if (!submitted) throw new Error("the auditor never submitted a review");
return submitted;
}
// ─── the pipeline status registry ─────────────────────────────────────────────
@@ -346,10 +408,9 @@ export function abortExtraction(chatId: string): boolean {
// ─── the pipeline ───────────────────────────────────────────────────────────
export interface ExtractionModels {
/** The extractor's model (tests inject a MockLanguageModelV4). */
extractor?: LanguageModel;
/** The auditor's model. */
auditor?: LanguageModel;
/** Test seam: a scripted engine per stage (production uses the roles). */
extractor?: PiEngine;
auditor?: PiEngine;
}
export interface ExtractionPipelineDeps extends GraphDeps, ExtractionModels {}
@@ -362,8 +423,6 @@ export interface ExtractionPipeline {
export function createExtractionPipeline(deps: ExtractionPipelineDeps): ExtractionPipeline {
const { chatStore, graphStore } = deps;
const extractorAgent = createExtractor(deps.extractor ?? modelFor("extractor"));
const auditorAgent = createAuditor(deps.auditor ?? modelFor("auditor"));
const inFlight = new Set<string>();
function setPhase(chatId: string, phase: ExtractionPhase): void {
@@ -385,22 +444,17 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
run.usage[stage]!.steps.push(usage);
}
/** The SDK's per-step usage, flattened to the persisted CallUsage fields
/** pi's per-step usage, flattened to the persisted CallUsage fields
* (the money channel's capture shape, contracts/chat.ts). */
function callUsage(usage: {
inputTokens: number | undefined;
outputTokens: number | undefined;
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number };
outputTokenDetails?: { reasoningTokens?: number };
}): CallUsage {
// Optional-chained: the SDK types the detail objects as required, but
// providers (and mocks) may leave them unset.
function callUsage(usage: Usage): CallUsage {
return {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cacheReadTokens: usage.inputTokenDetails?.cacheReadTokens,
cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens,
reasoningTokens: usage.outputTokenDetails?.reasoningTokens,
inputTokens: usage.input,
outputTokens: usage.output,
cacheReadTokens: usage.cacheRead,
cacheWriteTokens: usage.cacheWrite,
// Optional in pi's Usage (providers that never emit reasoning
// tokens leave it unset) — persisted as 0, never dropped.
reasoningTokens: usage.reasoning ?? 0,
};
}
@@ -413,23 +467,17 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
console.info(`[extraction ${chatId}] extractor starting (${view.split("\n").length} transcript lines)`);
const t0 = performance.now();
const extraction = await extractorAgent.generate({
prompt: `Transcript:\n\n${view}`,
abortSignal: signal,
onStepFinish: (step) => {
recordStep(chatId, "extractor", callUsage(step.usage), step.model.modelId);
console.info(
`[extraction] extractor step ${step.stepNumber}: ${step.finishReason}, tools: ${step.toolCalls.map((c) => c.toolName).join(", ") || "(none)"}`,
);
const findings = await runExtractor(
deps.extractor,
`Transcript:\n\n${view}`,
signal,
(usage, model, step, finishReason, tools) => {
recordStep(chatId, "extractor", callUsage(usage), model);
console.info(`[extraction] extractor step ${step}: ${finishReason}, tools: ${tools}`);
},
});
for (const part of extraction.content) {
if (part.type === "tool-result" && part.toolName === "submit_findings") {
console.info(`[extraction ${chatId}] extractor done in ${Math.round(performance.now() - t0)}ms`);
return part.output as SessionFindings;
}
}
throw new Error("the extractor never submitted findings");
);
console.info(`[extraction ${chatId}] extractor done in ${Math.round(performance.now() - t0)}ms`);
return findings;
}
async function audit(chatId: string, findings: SessionFindings): Promise<AuditResult> {
@@ -445,25 +493,16 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
);
const t0 = performance.now();
const review = await auditorAgent.generate({
prompt:
`Transcript:\n\n${view}\n\nProposed findings:\n\n${JSON.stringify(findings, null, 2)}\n\n` +
const auditResult = await runAuditor(
deps.auditor,
`Transcript:\n\n${view}\n\nProposed findings:\n\n${JSON.stringify(findings, null, 2)}\n\n` +
`Current knowledge graph (${graph.nodes.length} nodes):\n\n${graphView(graph.nodes)}`,
abortSignal: signal,
onStepFinish: (step) => {
recordStep(chatId, "auditor", callUsage(step.usage), step.model.modelId);
console.info(
`[extraction] auditor step ${step.stepNumber}: ${step.finishReason}, tools: ${step.toolCalls.map((c) => c.toolName).join(", ") || "(none)"}`,
);
signal,
(usage, model, step, finishReason, tools) => {
recordStep(chatId, "auditor", callUsage(usage), model);
console.info(`[extraction] auditor step ${step}: ${finishReason}, tools: ${tools}`);
},
});
let auditResult: AuditResult | null = null;
for (const part of review.content) {
if (part.type === "tool-result" && part.toolName === "submit_review") {
auditResult = part.output as AuditResult;
}
}
if (!auditResult) throw new Error("the auditor never submitted a review");
);
if (auditResult.dropped.length > 0) {
console.info(
`[extraction ${chatId}] audit dropped ${auditResult.dropped.length}: ` +
+130
View File
@@ -0,0 +1,130 @@
import { Agent, type AgentEvent, type AgentMessage, type AgentTool } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
import { openRouterApiKey, piEngineFor, thinkingLevelFor, type PiEngine } from "../models.ts";
import type { ModelRole } from "../../../config.ts";
/**
* The shared sub-agent runner (docs/pi-engine-swap.md §6): one helper every
* background agent (researcher, demo-builder, visual-maker, extractor,
* auditor) runs on — a stateless pi Agent built per call, driven to
* completion, with the seams the job runner and the money channel need.
*
* Error semantics mirror the old ToolLoopAgent.generate: provider/loop
* failures THROW (pi encodes them in the stream; this helper surfaces
* them), and an abort throws an AbortError so every existing
* `signal.aborted` early-return keeps working unchanged.
*/
export interface SubagentRunOptions {
/** The role whose engine (model + thinking level) this run uses. */
role: ModelRole;
/** Test seam: a scripted engine replaces the role's production one. */
engine?: PiEngine;
systemPrompt: string;
/** Initial transcript (continuation semantics: ends user / toolResult). */
messages?: AgentMessage[];
/** Fresh-run prompt text (mutually exclusive with a continuation). */
prompt?: string;
/** Max model calls (the old stepCountIs safety net). */
maxTurns: number;
/** Abort mid-model-call (the real-kill semantics). */
abortSignal?: AbortSignal;
/** Money channel: one call per model request. */
onUsage?: (usage: Usage) => void;
/** Activity line per tool execution (job record progress). */
onToolStart?: (toolName: string, args: unknown) => void;
/** Per completed assistant message (extraction's step log). */
onAssistantMessage?: (message: AssistantMessage, step: number) => void;
/** Extra stop rule (extractor: stop after a clean submit). */
shouldStop?: (message: AssistantMessage, toolResults: ToolResultMessage[]) => boolean;
}
export interface SubagentRunResult {
/** The messages this run added (assistant + tool results). */
newMessages: AgentMessage[];
/** The final assistant text (the deliverable). */
text: string;
}
export function subagentEngine(role: ModelRole, override?: PiEngine): PiEngine {
return override ?? piEngineFor(role);
}
export async function runSubagent(tools: AgentTool<any, any>[], opts: SubagentRunOptions): Promise<SubagentRunResult> {
const engine = subagentEngine(opts.role, opts.engine);
let turns = 0;
const agent = new Agent({
initialState: {
systemPrompt: opts.systemPrompt,
model: engine.model,
thinkingLevel: thinkingLevelFor(opts.role),
tools,
messages: opts.messages ?? [],
},
streamFn: engine.streamFn,
getApiKey: () => openRouterApiKey(),
shouldStopAfterTurn: (context) => {
turns++;
return turns >= opts.maxTurns || opts.shouldStop?.(context.message, context.toolResults) === true;
},
});
const onAbort = () => agent.abort();
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
const before = opts.messages?.length ?? 0;
let step = 0;
let errorMessage: string | undefined;
let aborted = false;
try {
agent.subscribe((event: AgentEvent) => {
switch (event.type) {
case "message_end":
if ((event.message as { role?: string }).role === "assistant") {
const m = event.message as AssistantMessage;
if (m.stopReason === "aborted") {
// The call was cancelled — no completed step, no billed
// usage (the abort path asserts zero steps).
aborted = true;
break;
}
step++;
opts.onUsage?.(m.usage);
opts.onAssistantMessage?.(m, step);
if (m.stopReason === "error") errorMessage = m.errorMessage;
}
break;
case "tool_execution_start":
opts.onToolStart?.(event.toolName, event.args);
break;
}
});
if (opts.prompt !== undefined) await agent.prompt({
role: "user",
content: opts.prompt,
timestamp: Date.now(),
});
else await agent.continue();
} finally {
opts.abortSignal?.removeEventListener("abort", onAbort);
}
if (aborted || opts.abortSignal?.aborted) {
const err = new Error("aborted");
err.name = "AbortError";
throw err;
}
if (errorMessage !== undefined) throw new Error(errorMessage);
const newMessages = agent.state.messages.slice(before);
const assistants = newMessages.filter((m) => (m as { role?: string }).role === "assistant") as AssistantMessage[];
const last = assistants[assistants.length - 1];
const text = last
? last.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("")
: "";
return { newMessages, text };
}
+30 -11
View File
@@ -1,6 +1,9 @@
import { ToolLoopAgent, stepCountIs, type LanguageModel } from "ai";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import type { Usage } from "@earendil-works/pi-ai";
import { webTools } from "../tools/web.ts";
import { modelFor } from "../models.ts";
import { toPiTools } from "../lh-tool.ts";
import type { PiEngine } from "../models.ts";
import { runSubagent } from "./pi-run.ts";
/**
* The researcher sub-agent (vision §2.5): a SEPARATE ToolLoopAgent instance
@@ -9,7 +12,7 @@ import { modelFor } from "../models.ts";
*
* System prompt adapted from the pi researcher agent
* (~/.pi/agent/extensions/interactive-subagents/agents/researcher.md).
* The model is injectable so tests run the loop with a MockLanguageModelV4.
* The engine is injectable so tests run the loop with a scripted faux engine.
*/
const INSTRUCTIONS = `You are a research specialist. You work for a teacher agent inside a learning
@@ -57,13 +60,29 @@ Numbered findings with inline source citations:
## Gaps
What couldn't be answered. Suggested next steps.`;
export function createResearcherAgent(model?: LanguageModel) {
return new ToolLoopAgent({
model: model ?? modelFor("researcher"),
instructions: INSTRUCTIONS,
tools: webTools,
stopWhen: stepCountIs(12),
});
export interface ResearcherRunOptions {
engine?: PiEngine;
abortSignal?: AbortSignal;
onUsage?: (usage: Usage) => void;
onToolStart?: (toolName: string, args: unknown) => void;
}
export type ResearcherAgent = ReturnType<typeof createResearcherAgent>;
/**
* Run the researcher to completion: isolated context, web tools only, final
* text = the brief. The engine is injectable so tests run the loop scripted.
*/
export async function runResearcherAgent(prompt: string, opts: ResearcherRunOptions = {}): Promise<string> {
const { text } = await runSubagent(toPiTools(webTools) as AgentTool<any, any>[], {
role: "researcher",
prompt,
engine: opts.engine,
systemPrompt: INSTRUCTIONS,
maxTurns: 12,
abortSignal: opts.abortSignal,
onUsage: opts.onUsage,
onToolStart: opts.onToolStart,
});
return text;
}
export type ResearcherAgent = ReturnType<typeof runResearcherAgent>;
+24 -25
View File
@@ -1,7 +1,9 @@
import { isToolUIPart, type LanguageModel, type UIMessage } from "ai";
import { isToolUIPart, type UIMessage } from "ai";
import type { PiEngine } from "../models.ts";
import {
accumulateJobUsage,
buildSubagentSteer,
piUsageToStep,
type DemoResult,
type DemoToolInput,
type JobKind,
@@ -18,7 +20,7 @@ import { registerJobAbort, unregisterJobAbort } from "./aborts.ts";
import { MODELS } from "../../../config.ts";
import type { ChatStore, JobStore } from "../../core/ports.ts";
import { runDemoBuilder } from "./demo-builder.ts";
import { createResearcherAgent } from "./researcher.ts";
import { runResearcherAgent } from "./researcher.ts";
import { runVisualMaker } from "./visual-maker.ts";
/**
@@ -42,13 +44,13 @@ import { runVisualMaker } from "./visual-maker.ts";
/**
* Test seam: the spawn wiring has no injection point at the tool layer, so
* tests swap in a MockLanguageModelV4 here to keep job tests off the network.
* tests swap in a scripted pi engine here to keep job tests off the network.
* Undefined in production. (The jobs spawn through the module-level KIND
* runners, which read these at call time.)
*/
export const researchModelOverride: { current?: LanguageModel } = {};
export const demoModelOverride: { current?: LanguageModel } = {};
export const visualModelOverride: { current?: LanguageModel } = {};
export const researchModelOverride: { current?: PiEngine } = {};
export const demoModelOverride: { current?: PiEngine } = {};
export const visualModelOverride: { current?: PiEngine } = {};
/** The dispatch-time tool input (from src/agent/tools/jobs.ts schemas). */
type JobToolInput = ResearchToolInput | DemoToolInput | VisualToolInput;
@@ -232,7 +234,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
async function runResearchJob(
jobId: string,
model: LanguageModel | undefined,
model: PiEngine | undefined,
passedInput: JobToolInput | undefined,
loop: LoopContext,
): Promise<void> {
@@ -253,28 +255,25 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
.join("\n\n");
const fetchedUrls: string[] = [];
const agent = createResearcherAgent(model);
try {
jobStore.updateJob(jobId, { activity: "researching" });
const result = await agent.generate({
prompt,
const brief = await runResearcherAgent(prompt, {
engine: model,
abortSignal: loop.abortSignal,
// Money channel: each agent step is one model call — fold its
// usage into the record as it completes (progress write).
onStepFinish: ({ usage }) => loop.onUsage(usage),
onToolExecutionStart: ({ toolCall }) => {
const input = toolCall.input as { query?: string; url?: string } | undefined;
if (toolCall.toolName === "web_fetch" && typeof input?.url === "string") {
onUsage: (usage) => loop.onUsage(piUsageToStep(usage)),
onToolStart: (toolName, args) => {
const input = args as { query?: string; url?: string } | undefined;
if (toolName === "web_fetch" && typeof input?.url === "string") {
fetchedUrls.push(input.url);
}
jobStore.updateJob(jobId, { activity: activityLine(toolCall.toolName, input) });
jobStore.updateJob(jobId, { activity: activityLine(toolName, input) });
},
});
const brief = result.text.trim();
if (!brief) throw new Error("the researcher produced no text");
const research: ResearchResult = { brief, sources: parseSources(brief, fetchedUrls) };
if (!brief.trim()) throw new Error("the researcher produced no text");
const research: ResearchResult = { brief: brief.trim(), sources: parseSources(brief.trim(), fetchedUrls) };
await finishJob(jobId, research);
} catch (err) {
// Real-kill abort (2026-09): the abort path flipped the record and
@@ -291,7 +290,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
async function runDemoJob(
jobId: string,
model: LanguageModel | undefined,
model: PiEngine | undefined,
passedInput: JobToolInput | undefined,
loop: LoopContext,
): Promise<void> {
@@ -308,9 +307,9 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
const result = await runDemoBuilder(
{ title, brief },
{
model,
engine: model,
abortSignal: loop.abortSignal,
onUsage: loop.onUsage,
onUsage: (usage) => loop.onUsage(piUsageToStep(usage)),
onActivity: (line) => jobStore.updateJob(jobId, { activity: line }),
},
);
@@ -327,7 +326,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
async function runVisualJob(
jobId: string,
model: LanguageModel | undefined,
model: PiEngine | undefined,
passedInput: JobToolInput | undefined,
loop: LoopContext,
): Promise<void> {
@@ -343,9 +342,9 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
const result = await runVisualMaker(
{ brief },
{
model,
engine: model,
abortSignal: loop.abortSignal,
onUsage: loop.onUsage,
onUsage: (usage) => loop.onUsage(piUsageToStep(usage)),
onActivity: (line) => jobStore.updateJob(jobId, { activity: line }),
},
);
+101 -48
View File
@@ -1,7 +1,8 @@
import { generateText, tool, type LanguageModel, type ModelMessage, type ToolResultPart } from "ai";
import type { AssistantMessage, Message, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
import { z } from "zod";
import { modelFor } from "../models.ts";
import type { UsageStepLike, VisualResult } from "../../core/contracts.ts";
import { lhTool, piToolDeclarations, type LhTool } from "../lh-tool.ts";
import { piEngineFor, thinkingLevelFor, type PiEngine } from "../models.ts";
import type { VisualResult } from "../../core/contracts.ts";
import { getHeadless, type HeadlessApi } from "./headless.ts";
/**
@@ -31,7 +32,8 @@ export interface VisualMakerInput {
}
export interface VisualMakerOptions {
model?: LanguageModel;
/** Test seam: scripted engine; production uses the visualMaker role. */
engine?: PiEngine;
headless?: HeadlessApi;
/** Max model rounds (write/render cycles + final answer). Default 8. */
maxIterations?: number;
@@ -39,9 +41,9 @@ export interface VisualMakerOptions {
onActivity?: (line: string) => void;
/** Kills the run mid-model-call (2026-09 real-kill abort semantics). */
abortSignal?: AbortSignal;
/** Money channel: one call per loop iteration (generateText), folded
/** Money channel: one call per loop iteration (streamSimple), folded
* into JobRecord.usage. */
onUsage?: (usage: UsageStepLike) => void;
onUsage?: (usage: Usage) => void;
}
const INSTRUCTIONS = `You are a visual maker — a diagram author + renderer for precise, spatial figures. You
@@ -86,25 +88,28 @@ Your FINAL message: 1-2 sentences of notes for the teacher (what the figure show
NEVER the SVG source itself. If you genuinely cannot make a correct picture of the brief, say exactly
that in your final message and why.`;
export function createVisualTools() {
export function createVisualTools(): Record<string, LhTool<any, any>> {
return {
write_svg: tool({
write_svg: lhTool({
name: "write_svg",
description: "Write the complete SVG source for the figure (overwrites any previous version).",
inputSchema: z.object({
schema: z.object({
svg: z.string().describe('Complete <svg xmlns="…">…</svg> markup.'),
}),
}),
edit_svg: tool({
edit_svg: lhTool({
name: "edit_svg",
description:
"Replace one exact spot in the current SVG: old_text must appear EXACTLY ONCE and is replaced by new_text. Call write_svg first. For larger changes, write_svg the whole document.",
inputSchema: z.object({
schema: z.object({
old_text: z.string().describe("The exact text to replace — must match exactly once in the current SVG."),
new_text: z.string().describe("The replacement text."),
}),
}),
}),
render_svg: tool({
render_svg: lhTool({
name: "render_svg",
description: "Render the current SVG to a PNG and show it to you for inspection. No arguments.",
inputSchema: z.object({}),
schema: z.object({}),
}),
};
}
@@ -125,6 +130,9 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
}
const tools = createVisualTools();
const toolDeclarations = piToolDeclarations(tools);
const engine = opts.engine ?? piEngineFor("visualMaker");
const reasoning = thinkingLevelFor("visualMaker");
const prompt = [
"Brief for ONE precise figure (from the teacher, verbatim — it is everything you know):",
brief,
@@ -132,7 +140,7 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
"Author the SVG, render it, look at it, iterate until correct, then finish with your notes.",
].join("\n");
let messages: ModelMessage[] = [{ role: "user", content: prompt }];
let messages: Message[] = [{ role: "user", content: prompt, timestamp: Date.now() }];
let currentSvg: string | null = null;
/** The svg shown in the most recent render (null = never rendered). */
let renderedSvg: string | null = null;
@@ -140,20 +148,20 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
try {
for (let iteration = 0; iteration < maxIterations; iteration++) {
const result = await generateText({
model: opts.model ?? modelFor("visualMaker"),
system: INSTRUCTIONS,
messages,
tools,
abortSignal: opts.abortSignal,
});
// One model call per iteration (streamSimple, no agent loop — this
// loop owns tool execution and the image-in-the-loop injection).
const result = await streamOnce(engine, INSTRUCTIONS, messages, toolDeclarations, reasoning, opts.abortSignal);
// Money channel: each iteration is one model call.
opts.onUsage?.(result.usage);
messages = [...messages, ...result.response.messages];
messages = [...messages, result];
const toolCalls = result.toolCalls;
const toolCalls = result.content.filter((c): c is Extract<typeof c, { type: "toolCall" }> => c.type === "toolCall");
if (toolCalls.length === 0) {
finalText = result.text.trim();
finalText = result.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("")
.trim();
// Doctrine gate: never deliver an unlooked SVG. If the current SVG
// changed since its last render (or was never rendered), show it now
// and make the model look before accepting the finish.
@@ -169,41 +177,48 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
renderedSvg = currentSvg;
messages = [
...messages,
{ role: "user", content: [{ type: "file", data: png, mediaType: "image/png" }, { type: "text", text: UNLOOKED_FINISH_MESSAGE }] },
{
role: "user",
content: [
{ type: "text", text: UNLOOKED_FINISH_MESSAGE },
{ type: "image", data: png.toString("base64"), mimeType: "image/png" },
],
timestamp: Date.now(),
},
];
continue;
}
return finish(currentSvg, finalText);
}
const toolResults: ToolResultPart[] = [];
const toolResults: ToolResultMessage[] = [];
let renderPng: Buffer | null = null; // appended as the image message AFTER the tool results
for (const call of toolCalls) {
if (call.toolName === "write_svg") {
const svg = (call.input as { svg?: unknown }).svg;
if (call.name === "write_svg") {
const svg = (call.arguments as { svg?: unknown }).svg;
if (typeof svg !== "string" || !svg.includes("<svg")) {
toolResults.push(toolText(call.toolCallId, call.toolName, "ERROR: expected a complete <svg>…</svg> document in the `svg` field."));
toolResults.push(toolText(call.id, call.name, "ERROR: expected a complete <svg>…</svg> document in the `svg` field."));
continue;
}
currentSvg = svg;
opts.onActivity?.(`write_svg: ${(svg.length / 1024).toFixed(1)} KB`);
toolResults.push(toolText(call.toolCallId, call.toolName, `Saved (${svg.length} bytes). Call render_svg to look at it.`));
} else if (call.toolName === "edit_svg") {
const edit = call.input as { old_text?: unknown; new_text?: unknown };
toolResults.push(toolText(call.id, call.name, `Saved (${svg.length} bytes). Call render_svg to look at it.`));
} else if (call.name === "edit_svg") {
const edit = call.arguments as { old_text?: unknown; new_text?: unknown };
if (!currentSvg) {
toolResults.push(toolText(call.toolCallId, call.toolName, "ERROR: nothing to edit — call write_svg first."));
toolResults.push(toolText(call.id, call.name, "ERROR: nothing to edit — call write_svg first."));
continue;
}
if (typeof edit.old_text !== "string" || typeof edit.new_text !== "string" || !edit.old_text) {
toolResults.push(toolText(call.toolCallId, call.toolName, "ERROR: expected old_text (non-empty) and new_text strings."));
toolResults.push(toolText(call.id, call.name, "ERROR: expected old_text (non-empty) and new_text strings."));
continue;
}
const occurrences = currentSvg.split(edit.old_text).length - 1;
if (occurrences === 0) {
toolResults.push(
toolText(
call.toolCallId,
call.toolName,
call.id,
call.name,
`ERROR: old_text not found. Closest line for reference:\n${snippetAround(currentSvg, edit.old_text)}`,
),
);
@@ -211,16 +226,16 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
}
if (occurrences > 1) {
toolResults.push(
toolText(call.toolCallId, call.toolName, `ERROR: old_text appears ${occurrences} times — it must match EXACTLY ONCE. Include more surrounding context to make it unique.`),
toolText(call.id, call.name, `ERROR: old_text appears ${occurrences} times — it must match EXACTLY ONCE. Include more surrounding context to make it unique.`),
);
continue;
}
currentSvg = currentSvg.replace(edit.old_text, edit.new_text);
opts.onActivity?.("edit_svg: applied");
toolResults.push(toolText(call.toolCallId, call.toolName, "OK — replaced 1 occurrence. Call render_svg to look at the edited SVG."));
} else if (call.toolName === "render_svg") {
toolResults.push(toolText(call.id, call.name, "OK — replaced 1 occurrence. Call render_svg to look at the edited SVG."));
} else if (call.name === "render_svg") {
if (!currentSvg) {
toolResults.push(toolText(call.toolCallId, call.toolName, "ERROR: nothing to render — call write_svg first."));
toolResults.push(toolText(call.id, call.name, "ERROR: nothing to render — call write_svg first."));
continue;
}
try {
@@ -228,18 +243,18 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
renderPng = png;
renderedSvg = currentSvg;
opts.onActivity?.("render_svg: inspecting render");
toolResults.push(toolText(call.toolCallId, call.toolName, "Rendered — the PNG follows in the next message. Look at it."));
toolResults.push(toolText(call.id, call.name, "Rendered — the PNG follows in the next message. Look at it."));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
opts.onActivity?.("render_svg: render error");
toolResults.push(toolText(call.toolCallId, call.toolName, `RENDER ERROR (the SVG may be malformed): ${msg}. Fix it with write_svg and render again.`));
toolResults.push(toolText(call.id, call.name, `RENDER ERROR (the SVG may be malformed): ${msg}. Fix it with write_svg and render again.`));
}
} else {
toolResults.push(toolText(call.toolCallId, call.toolName, `ERROR: unknown tool "${call.toolName}". Use write_svg, edit_svg, or render_svg.`));
toolResults.push(toolText(call.id, call.name, `ERROR: unknown tool "${call.name}". Use write_svg, edit_svg, or render_svg.`));
}
}
// Message order: assistant tool-calls → tool results → render image.
messages = [...messages, { role: "tool", content: toolResults }];
messages = [...messages, ...toolResults];
if (renderPng) {
messages = [
...messages,
@@ -247,8 +262,9 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
role: "user",
content: [
{ type: "text", text: LOOK_MESSAGE },
{ type: "file", data: renderPng, mediaType: "image/png" },
{ type: "image", data: renderPng.toString("base64"), mimeType: "image/png" },
],
timestamp: Date.now(),
},
];
}
@@ -265,8 +281,45 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
return { svg: "", notes: `FAILED: iteration limit (${maxIterations}) reached without a verified SVG.` };
}
function toolText(toolCallId: string, toolName: string, value: string): ToolResultPart {
return { type: "tool-result", toolCallId, toolName, output: { type: "text", value } };
function toolText(toolCallId: string, toolName: string, value: string): ToolResultMessage {
return {
role: "toolResult",
toolCallId,
toolName,
content: [{ type: "text", text: value }],
isError: value.startsWith("ERROR:") || value.startsWith("RENDER ERROR"),
timestamp: Date.now(),
};
}
/** One raw model call (no agent loop): drain the event stream to the final
* AssistantMessage; provider errors and aborts throw so the loop's catch
* path keeps its old generateText semantics. */
async function streamOnce(
engine: PiEngine,
systemPrompt: string,
messages: Message[],
tools: ReturnType<typeof piToolDeclarations>,
reasoning: ReturnType<typeof thinkingLevelFor>,
abortSignal?: AbortSignal,
): Promise<AssistantMessage> {
const stream = engine.streamFn(engine.model, { systemPrompt, messages, tools }, {
reasoning: reasoning === "off" ? undefined : reasoning,
apiKey: process.env.OPENROUTER_API_KEY || undefined,
signal: abortSignal,
} as never);
for await (const event of stream) {
if (event.type === "done") return event.message;
if (event.type === "error") {
if (event.reason === "aborted") {
const err = new Error("aborted");
err.name = "AbortError";
throw err;
}
throw new Error(event.error.errorMessage ?? "the model call failed");
}
}
throw new Error("the model stream ended without a result");
}
/** A 3-line numbered context window around the closest fuzzy match for a