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:
@@ -0,0 +1,109 @@
|
||||
import { z } from "zod";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
|
||||
/**
|
||||
* lh's neutral tool definition + the converter to pi-agent-core's AgentTool
|
||||
* (docs/pi-engine-swap.md §3). The agent-band swap must not re-risk the
|
||||
* battle-tested zod authoring contracts (the quiz correctAnswer wording,
|
||||
* the nodeId superRefines) — so tools are AUTHORED exactly as before
|
||||
* (zod schemas, same execute signatures) in this shape, and converted
|
||||
* once, centrally, here.
|
||||
*
|
||||
* What the converter owns (the semantics the Vercel SDK gave implicitly):
|
||||
* - parameters: z.toJSONSchema feeds the model-facing declaration (pi
|
||||
* structurally validates plain JSON Schema itself).
|
||||
* - refinements: superRefine rules don't survive JSON Schema — the schema
|
||||
* is re-run inside execute, and a failure THROWS. pi's loop catches
|
||||
* execute throws and turns them into an error tool result the model
|
||||
* sees and retries on — the exact InvalidToolInputError semantics.
|
||||
* - client question tools (quiz / ask_user_question): execute returns a
|
||||
* synthetic "question posed" placeholder that is NEVER persisted — the
|
||||
* record keeps the part output-less until the client answers; the
|
||||
* learner's answer re-enters the next turn as the tool result. The
|
||||
* turn itself stops via shouldStopAfterTurn (teacher-agent.ts).
|
||||
*/
|
||||
|
||||
export interface LhTool<TInput = any, TResult = any> {
|
||||
name: string;
|
||||
description: string;
|
||||
/** The authoring contract (zod, refinements included). */
|
||||
schema: z.ZodType<TInput>;
|
||||
/**
|
||||
* Server-side execution. Absent + clientQuestion = the client answers.
|
||||
* Throws on validation failure — the loop converts to an error result.
|
||||
*/
|
||||
execute?: (input: TInput, ctx: { toolCallId: string }) => TResult | Promise<TResult>;
|
||||
/** Client-answered question tool (quiz/ask): a call ends the turn. */
|
||||
clientQuestion?: boolean;
|
||||
}
|
||||
|
||||
/** Author a tool (the `tool({...})` shape, engine-neutral). */
|
||||
export function lhTool<TInput, TResult = unknown>(def: LhTool<TInput, TResult>): LhTool<TInput, TResult> {
|
||||
return def;
|
||||
}
|
||||
|
||||
/** lh's port of the SDK's invalid-input message: what the model retried on. */
|
||||
export class ToolInputError extends Error {
|
||||
constructor(
|
||||
public readonly toolName: string,
|
||||
public readonly zodError: z.ZodError,
|
||||
) {
|
||||
super(
|
||||
`Invalid tool input for tool "${toolName}":\n${zodError.issues
|
||||
.map((issue) => {
|
||||
const path = issue.path.length > 0 ? ` at ${issue.path.join(".")}` : "";
|
||||
return `- ${issue.message}${path}`;
|
||||
})
|
||||
.join("\n")}`,
|
||||
);
|
||||
this.name = "ToolInputError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert one lh tool to a pi AgentTool. */
|
||||
export function toPiTool(def: LhTool<any, any>): AgentTool<any, any> {
|
||||
return {
|
||||
name: def.name,
|
||||
label: def.name,
|
||||
description: def.description,
|
||||
parameters: z.toJSONSchema(def.schema as z.ZodType) as never,
|
||||
execute: async (toolCallId, args) => {
|
||||
// The authoring check: structural JSON Schema validation already
|
||||
// ran in the loop's prepare step; this re-run adds the refinements
|
||||
// (superRefine rules) the JSON Schema can't carry.
|
||||
const parsed = def.schema.safeParse(args);
|
||||
if (!parsed.success) throw new ToolInputError(def.name, parsed.error);
|
||||
if (def.clientQuestion || !def.execute) {
|
||||
// The synthetic placeholder — in-memory only; the record's tool
|
||||
// part keeps no output until the learner answers.
|
||||
return {
|
||||
content: [{ type: "text", text: "Question posed — the turn ends here; the learner's graded answer arrives as this call's result." }],
|
||||
details: null,
|
||||
};
|
||||
}
|
||||
const result = await def.execute(parsed.data, { toolCallId });
|
||||
return {
|
||||
// The model's view: today's parity — string results pass verbatim,
|
||||
// objects as JSON.
|
||||
content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a tool registry (name → LhTool) to pi's AgentTool[]. */
|
||||
export function toPiTools(registry: Record<string, LhTool<any, any>>): AgentTool<any, any>[] {
|
||||
return Object.entries(registry).map(([name, def]) => toPiTool({ ...def, name }));
|
||||
}
|
||||
|
||||
/** Declaration-only pi Tools (name/description/parameters, no execute) —
|
||||
* for hand-rolled loops that own tool execution themselves. */
|
||||
export function piToolDeclarations(registry: Record<string, LhTool<any, any>>) {
|
||||
return Object.entries(registry).map(([name, def]) => ({
|
||||
name,
|
||||
label: name,
|
||||
description: def.description,
|
||||
parameters: z.toJSONSchema(def.schema as z.ZodType) as never,
|
||||
}));
|
||||
}
|
||||
+68
-12
@@ -1,17 +1,73 @@
|
||||
import { openrouter } from "@openrouter/ai-sdk-provider";
|
||||
import type { LanguageModel } from "ai";
|
||||
import {
|
||||
createModels,
|
||||
type Api,
|
||||
type Model,
|
||||
type Models,
|
||||
type StreamFunction,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { openrouterProvider } from "@earendil-works/pi-ai/providers/openrouter";
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { MODELS, type ModelRole } from "../../config.ts";
|
||||
|
||||
/**
|
||||
* The one factory every agent's model comes from. Model choice and thinking
|
||||
* budget live in config.ts (MODELS) — the single tuning table; this module
|
||||
* only wires them to OpenRouter. `effort: "none"` is OpenRouter's off-switch
|
||||
* for reasoning tokens; higher levels let the model think longer (and bill
|
||||
* accordingly).
|
||||
* The one engine factory every agent's model comes from (the pi-ai swap,
|
||||
* docs/pi-engine-swap.md). Model choice and thinking budget live in
|
||||
* config.ts (MODELS) — the single tuning table; this module only wires them
|
||||
* to pi-ai's OpenRouter provider.
|
||||
*
|
||||
* An "engine" is the model + the stream function the agent loop runs on —
|
||||
* the two things a test fake replaces. Production engines resolve the model
|
||||
* from pi-ai's static catalog (glm-5.3-flash and qwen3.8-max are both in
|
||||
* 0.85.1's built-ins — no overlay fetch) and stream through the same Models
|
||||
* collection.
|
||||
*/
|
||||
export function modelFor(role: ModelRole): LanguageModel {
|
||||
const spec = MODELS[role];
|
||||
return openrouter(spec.model, {
|
||||
reasoning: { enabled: true, effort: spec.effort },
|
||||
});
|
||||
|
||||
export interface PiEngine {
|
||||
model: Model<Api>;
|
||||
streamFn: StreamFunction;
|
||||
}
|
||||
|
||||
export type { ThinkingLevel };
|
||||
|
||||
/** config's effort scale maps 1:1 onto pi's thinking levels (none→off). */
|
||||
export function thinkingLevelFor(role: ModelRole): ThinkingLevel {
|
||||
const effort = MODELS[role].effort as string;
|
||||
return effort === "none" ? "off" : (effort as ThinkingLevel);
|
||||
}
|
||||
|
||||
let collection: ReturnType<typeof createModels> | undefined;
|
||||
|
||||
/** The process-wide pi-ai Models collection (OpenRouter catalog). */
|
||||
export function piModels() {
|
||||
if (!collection) {
|
||||
collection = createModels();
|
||||
collection.setProvider(openrouterProvider());
|
||||
}
|
||||
return collection;
|
||||
}
|
||||
|
||||
/** OpenRouter API key (BYOK); tests never read this — they inject engines. */
|
||||
export function openRouterApiKey(): string | undefined {
|
||||
return process.env.OPENROUTER_API_KEY || undefined;
|
||||
}
|
||||
|
||||
/** The production engine for one agent role (config.ts MODELS). */
|
||||
export function piEngineFor(role: ModelRole): PiEngine {
|
||||
const models: Models = piModels();
|
||||
const spec = MODELS[role];
|
||||
const model = models.getModel("openrouter", spec.model);
|
||||
if (!model) throw new Error(`OpenRouter catalog has no model ${spec.model} (role ${role})`);
|
||||
const level = thinkingLevelFor(role);
|
||||
return {
|
||||
model,
|
||||
// bound streamSimple: the agent loop forwards state.thinkingLevel as
|
||||
// `reasoning` (undefined = off on pi-ai's scale); pin the role's level
|
||||
// so a bare engine can never drift.
|
||||
streamFn: (requestModel, context, options) =>
|
||||
models.streamSimple(requestModel, context, {
|
||||
...options,
|
||||
reasoning: level === "off" ? undefined : level,
|
||||
apiKey: options?.apiKey ?? openRouterApiKey(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}: ` +
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
+106
-47
@@ -1,8 +1,11 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { ToolLoopAgent, stepCountIs, tool, type LanguageModel, type LanguageModelUsage, type ToolSet } from "ai";
|
||||
import { Agent } from "@earendil-works/pi-agent-core";
|
||||
import type { AgentEvent, AgentMessage, AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
import { PATHS } from "../../config.ts";
|
||||
import { modelFor } from "./models.ts";
|
||||
import { MODELS } from "../../config.ts";
|
||||
import { piEngineFor, thinkingLevelFor, openRouterApiKey, type PiEngine } from "./models.ts";
|
||||
import {
|
||||
askUserQuestionTool,
|
||||
createProberTools,
|
||||
@@ -10,17 +13,20 @@ import {
|
||||
type ProbeDoneContext,
|
||||
type TeacherToolContext,
|
||||
} from "./tools.ts";
|
||||
import { toPiTools, type LhTool } from "./lh-tool.ts";
|
||||
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
|
||||
import { QUESTION_TOOLS } from "../core/pending.ts";
|
||||
|
||||
/**
|
||||
* The two teaching-side agents (docs/probe-teach-split.md): the PROBER and
|
||||
* the TEACHER. Same harness (agent loop, tool calling, streaming,
|
||||
* multi-step — the few lines ToolLoopAgent provides), same transcript, but
|
||||
* different prompts and different tool sets; the chat-handler constructs
|
||||
* one per request off the transcript's seam (probe_done = teacher).
|
||||
* the TEACHER. Same transcript, different prompts and tool sets; the
|
||||
* chat-handler constructs ONE per request off the transcript's seam
|
||||
* (probe_done = teacher) — the STATELESS per-turn engine (docs/
|
||||
* pi-engine-swap.md §2): the persisted record is the source of truth, the
|
||||
* agent is rebuilt from it every POST and discarded at turn end.
|
||||
*
|
||||
* Models are injectable so tests can pass a MockLanguageModelV4 and drive
|
||||
* deterministic scripted lessons (see tests/chat-flow.test.ts).
|
||||
* Engines are injectable so tests can pass a scripted faux engine and drive
|
||||
* deterministic lessons (see tests/chat-flow.test.ts).
|
||||
*
|
||||
* System prompts are COMPOSED per agent: system-prompt.md (the shared base:
|
||||
* philosophy, accuracy, quiz construction, question tools, formatting) +
|
||||
@@ -62,9 +68,81 @@ const composedInstructions: Record<AgentKind, string> = {
|
||||
|
||||
// Money channel (wave 4): the chat-handler passes a per-turn collector;
|
||||
// every model call's usage lands in the chat record's turns log
|
||||
// (ChatStore.appendTurnUsage). The step carries far more than usage —
|
||||
// the narrow param keeps the seam honest about what we consume.
|
||||
export type StepUsageCollector = (step: { usage: LanguageModelUsage }) => void;
|
||||
// (ChatStore.appendTurnUsage). The narrow param keeps the seam honest.
|
||||
export type StepUsageCollector = (usage: Usage) => void;
|
||||
|
||||
/** Per-request construction options (the handler wires all of these). */
|
||||
export interface AgentBuildOptions {
|
||||
/** Model + stream function — tests inject a scripted faux engine. */
|
||||
engine?: PiEngine;
|
||||
/** The initial transcript, as pi messages (recordToPiMessages). */
|
||||
messages?: AgentMessage[];
|
||||
/** Money channel: one call per model request. */
|
||||
onUsage?: StepUsageCollector;
|
||||
/** Safety net (the old stepCountIs). Default 20 turns. */
|
||||
maxTurns?: number;
|
||||
/** Tests may pass a bare tool registry to drive deterministic flows. */
|
||||
tools?: Record<string, LhTool<any, any>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn's stop rule (docs/pi-engine-swap.md §2):
|
||||
* - a question tool call (quiz / ask_user_question) that RESOLVED — its
|
||||
* synthetic "question posed" result is not an error — ends the run: the
|
||||
* learner's answer arrives as the next POST. A call that FAILED authoring
|
||||
* validation (error result) must NOT stop the run — the model retries
|
||||
* within the same turn, exactly the InvalidToolInputError semantics.
|
||||
* - the maxTurns safety net replaces stepCountIs(20).
|
||||
*/
|
||||
function makeStopper(maxTurns: number) {
|
||||
let turns = 0;
|
||||
return (context: {
|
||||
message: { content: Array<{ type: string; id?: string; name?: string }> };
|
||||
toolResults: Array<{ toolCallId: string; isError?: boolean }>;
|
||||
}): boolean => {
|
||||
turns++;
|
||||
if (turns >= maxTurns) return true;
|
||||
const called = new Set(
|
||||
context.message.content
|
||||
.filter((c) => c.type === "toolCall" && c.name !== undefined && (QUESTION_TOOLS as readonly string[]).includes(c.name!))
|
||||
.map((c) => c.id!),
|
||||
);
|
||||
if (called.size === 0) return false;
|
||||
// Stop only when every question call of this turn resolved cleanly.
|
||||
for (const result of context.toolResults) {
|
||||
if (called.has(result.toolCallId) && result.isError) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: AgentTool<any, any>[]) {
|
||||
const engine = opts.engine ?? piEngineFor(kind);
|
||||
const stopper = makeStopper(opts.maxTurns ?? 20);
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt: composedInstructions[kind],
|
||||
model: engine.model,
|
||||
thinkingLevel: thinkingLevelFor(kind),
|
||||
tools,
|
||||
messages: opts.messages ?? [],
|
||||
},
|
||||
streamFn: engine.streamFn,
|
||||
getApiKey: () => openRouterApiKey(),
|
||||
shouldStopAfterTurn: stopper,
|
||||
});
|
||||
if (opts.onUsage) {
|
||||
agent.subscribe((event: AgentEvent) => {
|
||||
if (event.type === "message_end" && event.message.role === "assistant" && "usage" in event.message) {
|
||||
// A cancelled call is not a billed step (partial/estimated
|
||||
// usage on an aborted message is not money spent).
|
||||
if (event.message.stopReason === "aborted") return;
|
||||
opts.onUsage!(event.message.usage);
|
||||
}
|
||||
});
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* The PROBER (agent A): quiz (no nodeId) + ask + probe_done, lean on
|
||||
@@ -73,19 +151,8 @@ export type StepUsageCollector = (step: { usage: LanguageModelUsage }) => void;
|
||||
* handoff wake (see tools/probe.ts) and is the transcript seam the
|
||||
* handler keys teacher construction on.
|
||||
*/
|
||||
export function createProberAgent(ctx: ProbeDoneContext, model?: LanguageModel, onStepFinish?: StepUsageCollector) {
|
||||
return new ToolLoopAgent({
|
||||
// Model choice + thinking budget live in config.ts (MODELS.prober).
|
||||
// Reasoning tokens stream as reasoning parts into the UI's collapsible block.
|
||||
model: model ?? modelFor("prober"),
|
||||
instructions: composedInstructions.prober,
|
||||
tools: createProberTools(ctx),
|
||||
// Question tools are client-side (a call ends the server turn);
|
||||
// probe_done resolves immediately (it queues the handoff and acks).
|
||||
// stepCountIs is just the safety net.
|
||||
stopWhen: stepCountIs(20),
|
||||
...(onStepFinish ? { onStepFinish } : {}),
|
||||
});
|
||||
export function createProberAgent(ctx: ProbeDoneContext, opts: AgentBuildOptions = {}) {
|
||||
return baseAgentOptions("prober", opts, toPiTools(createProberTools(ctx)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,44 +166,36 @@ export function createTeacherAgent(
|
||||
// wiring): without ctx the tools fall back to the validation-free
|
||||
// question set. The handler ALWAYS passes ctx (the composition root).
|
||||
ctx?: TeacherToolContext,
|
||||
model?: LanguageModel,
|
||||
onStepFinish?: StepUsageCollector,
|
||||
// Tests may pass a bare tools set to drive deterministic flows; when
|
||||
// present it overrides the ctx-composed registry.
|
||||
tools?: ToolSet,
|
||||
opts: AgentBuildOptions = {},
|
||||
) {
|
||||
return new ToolLoopAgent({
|
||||
// Model choice + thinking budget live in config.ts (MODELS.teacher).
|
||||
model: model ?? modelFor("teacher"),
|
||||
instructions: composedInstructions.teacher,
|
||||
// Tools are per-request (they close over the injected ctx — chat-
|
||||
// handler passes the composition root's stores + spawn seam).
|
||||
tools: tools ?? (ctx ? createTeacherTools(ctx) : bareQuestionTools()),
|
||||
// Question tools are client-side (a call ends the server turn);
|
||||
// job tools resolve immediately (fire-and-forget ack). stepCountIs is
|
||||
// just the safety net.
|
||||
stopWhen: stepCountIs(20),
|
||||
...(onStepFinish ? { onStepFinish } : {}),
|
||||
});
|
||||
const tools = opts.tools ?? (ctx ? createTeacherTools(ctx) : bareQuestionTools());
|
||||
return baseAgentOptions("teacher", opts, toPiTools(tools));
|
||||
}
|
||||
|
||||
/** The validation-free teach question set (tests, bare agents): quiz with
|
||||
* the shared authoring refinement but NO per-session nodeId check (no
|
||||
* record to read), plus ask. */
|
||||
export function bareQuestionTools(): ToolSet {
|
||||
export function bareQuestionTools(): Record<string, LhTool<any, any>> {
|
||||
return {
|
||||
quiz: tool({
|
||||
quiz: {
|
||||
name: "quiz",
|
||||
description: "Ask the learner a graded question.",
|
||||
inputSchema: quizInputObject.superRefine((input, add) => {
|
||||
clientQuestion: true,
|
||||
schema: quizInputObject.superRefine((input, add) => {
|
||||
for (const problem of quizInputProblems(input)) {
|
||||
add.addIssue({ code: "custom", message: problem });
|
||||
}
|
||||
}),
|
||||
}),
|
||||
},
|
||||
ask_user_question: askUserQuestionTool,
|
||||
};
|
||||
}
|
||||
|
||||
export type ProberAgent = ReturnType<typeof createProberAgent>;
|
||||
export type TeacherAgent = ReturnType<typeof createTeacherAgent>;
|
||||
export type TeachingAgent = ProberAgent | TeacherAgent;
|
||||
export type TeachingAgent = ProberAgent | TeacherAgent;
|
||||
|
||||
/** The model id a role's engine resolves to (observability). */
|
||||
export function modelIdFor(kind: AgentKind): string {
|
||||
return MODELS[kind].model;
|
||||
}
|
||||
+17
-11
@@ -1,4 +1,4 @@
|
||||
import { tool, type ToolSet } from "ai";
|
||||
import { lhTool, type LhTool } from "./lh-tool.ts";
|
||||
import {
|
||||
askInputSchema,
|
||||
probeQuizInputSchema,
|
||||
@@ -33,17 +33,21 @@ export type { ProbeDoneContext } from "./tools/probe.ts";
|
||||
* only.
|
||||
*/
|
||||
|
||||
export const askUserQuestionTool = tool({
|
||||
export const askUserQuestionTool: LhTool<any, any> = {
|
||||
name: "ask_user_question",
|
||||
clientQuestion: true,
|
||||
description:
|
||||
"Ask the learner a single question and pause until they answer. NOT graded — there is no correct answer. " +
|
||||
"Use when requirements are ambiguous, preferences are needed, a decision would materially affect the lesson, " +
|
||||
"or you need an open-ended response. Ask exactly one question per tool call; after calling, end your turn and wait.",
|
||||
inputSchema: askInputSchema,
|
||||
});
|
||||
schema: askInputSchema,
|
||||
};
|
||||
|
||||
// ─── the prober's question tools (no nodeId — no map exists yet) ─────────────
|
||||
|
||||
export const probeQuizTool = tool({
|
||||
export const probeQuizTool: LhTool<any, any> = {
|
||||
name: "quiz",
|
||||
clientQuestion: true,
|
||||
description:
|
||||
"Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
|
||||
"Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a " +
|
||||
@@ -55,11 +59,11 @@ export const probeQuizTool = tool({
|
||||
"free-text note to ANY answer; it reaches you only when non-empty. No free-text answers — for non-graded " +
|
||||
"questions use ask_user_question instead. Ask exactly one question per tool call; after calling, end your " +
|
||||
"turn and wait for the graded result.",
|
||||
inputSchema: probeQuizInputSchema,
|
||||
});
|
||||
schema: probeQuizInputSchema,
|
||||
};
|
||||
|
||||
/** The prober's tool set: probe quiz (no nodeId) + ask + probe_done. */
|
||||
export function createProberTools(ctx: ProbeDoneContext): ToolSet {
|
||||
export function createProberTools(ctx: ProbeDoneContext): Record<string, LhTool<any, any>> {
|
||||
return {
|
||||
quiz: probeQuizTool,
|
||||
ask_user_question: askUserQuestionTool,
|
||||
@@ -96,7 +100,9 @@ export function teachQuizToolFor(ctx: Pick<PlanToolContext, "chatId" | "chatStor
|
||||
});
|
||||
}
|
||||
});
|
||||
return tool({
|
||||
return lhTool({
|
||||
name: "quiz",
|
||||
clientQuestion: true,
|
||||
description:
|
||||
"Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
|
||||
"Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a " +
|
||||
@@ -108,7 +114,7 @@ export function teachQuizToolFor(ctx: Pick<PlanToolContext, "chatId" | "chatStor
|
||||
"An always-present optional note field lets the learner attach a free-text note to ANY answer; it reaches you " +
|
||||
"only when non-empty. No free-text answers — for non-graded questions use ask_user_question instead. " +
|
||||
"Ask exactly one question per tool call; after calling, end your turn and wait for the graded result.",
|
||||
inputSchema,
|
||||
schema: inputSchema,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -126,7 +132,7 @@ export interface TeacherToolContext extends PlanToolContext {
|
||||
* Per-request teacher tool registry: teach questions (validated nodeId) +
|
||||
* plan tools + job tools, all closing over the ctx.
|
||||
*/
|
||||
export function createTeacherTools(ctx: TeacherToolContext): ToolSet {
|
||||
export function createTeacherTools(ctx: TeacherToolContext): Record<string, LhTool<any, any>> {
|
||||
return {
|
||||
quiz: teachQuizToolFor(ctx),
|
||||
ask_user_question: askUserQuestionTool,
|
||||
|
||||
+11
-8
@@ -1,5 +1,5 @@
|
||||
import { tool, type ToolSet } from "ai";
|
||||
import { z } from "zod";
|
||||
import { lhTool, type LhTool } from "../lh-tool.ts";
|
||||
import type { JobRecord } from "../../core/contracts.ts";
|
||||
import type { JobStore } from "../../core/ports.ts";
|
||||
|
||||
@@ -103,21 +103,24 @@ const VISUAL_DESCRIPTION =
|
||||
* 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(ctx: JobToolContext): ToolSet {
|
||||
export function jobTools(ctx: JobToolContext): Record<string, LhTool<any, any>> {
|
||||
return {
|
||||
research: tool({
|
||||
research: lhTool({
|
||||
name: "research",
|
||||
description: RESEARCH_DESCRIPTION,
|
||||
inputSchema: researchInputSchema,
|
||||
schema: researchInputSchema,
|
||||
execute: async (input, { toolCallId }) => dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input),
|
||||
}),
|
||||
demo: tool({
|
||||
demo: lhTool({
|
||||
name: "demo",
|
||||
description: DEMO_DESCRIPTION,
|
||||
inputSchema: demoInputSchema,
|
||||
schema: demoInputSchema,
|
||||
execute: async (input, { toolCallId }) => dispatch(ctx, "demo", toolCallId, DEMO_ACK, input),
|
||||
}),
|
||||
visual: tool({
|
||||
visual: lhTool({
|
||||
name: "visual",
|
||||
description: VISUAL_DESCRIPTION,
|
||||
inputSchema: visualInputSchema,
|
||||
schema: visualInputSchema,
|
||||
execute: async (input, { toolCallId }) => dispatch(ctx, "visual", toolCallId, VISUAL_ACK, input),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { tool, type ToolSet } from "ai";
|
||||
import { z } from "zod";
|
||||
import { lhTool, type LhTool } from "../lh-tool.ts";
|
||||
import type { PlanToolResult, PlanTree } from "../../core/contracts.ts";
|
||||
import type { ChatStore } from "../../core/ports.ts";
|
||||
import { createTree, emptyTree, PlanTreeError } from "../../core/plan-tree.ts";
|
||||
@@ -37,16 +37,17 @@ export interface PlanToolContext {
|
||||
* latest state straight from the transcript's tool outputs (the plan
|
||||
* window and the plan blocks).
|
||||
*/
|
||||
export function planTools(ctx: PlanToolContext): ToolSet {
|
||||
export function planTools(ctx: PlanToolContext): Record<string, LhTool<any, any>> {
|
||||
return {
|
||||
plan_init: tool({
|
||||
plan_init: lhTool({
|
||||
name: "plan_init",
|
||||
description:
|
||||
"Write or REWRITE this session's plan map — the dependency graph the learner follows live in the plan window. " +
|
||||
"Call once right after the probe concludes, to present the map; re-call whenever the map is wrong (the new block replaces the old tree). " +
|
||||
"A mermaid graph block (any direction): one node per line (`slug[Short Title]` — slugs are permanent lowercase ids: a-z, 0-9, hyphens/underscores), " +
|
||||
"`-->` edges meaning 'from must be understood before to' (chains fine). 5–15 nodes: a map, not a syllabus — one reasoning step per node, compress. " +
|
||||
"Every check you fire names the node it assesses via `nodeId`; where the lesson stands derives from those tags.",
|
||||
inputSchema: z.object({
|
||||
schema: z.object({
|
||||
mermaid: z
|
||||
.string()
|
||||
.min(1)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { tool, type ToolSet } from "ai";
|
||||
import { z } from "zod";
|
||||
import { lhTool, type LhTool } from "../lh-tool.ts";
|
||||
import type { ProbeDoneInput, ProbeDoneResult } from "../../core/contracts.ts";
|
||||
import type { UIMessage } from "ai";
|
||||
import { findPendingToolCall } from "../../core/pending.ts";
|
||||
@@ -44,9 +44,10 @@ function probeConcluded(messages: UIMessage[]): boolean {
|
||||
* The first probe_done in the transcript is also the construction seam:
|
||||
* from that message on, every request builds the TEACHER agent.
|
||||
*/
|
||||
export function probeDoneTool(ctx: ProbeDoneContext): ToolSet {
|
||||
export function probeDoneTool(ctx: ProbeDoneContext): Record<string, LhTool<any, any>> {
|
||||
return {
|
||||
probe_done: tool({
|
||||
probe_done: lhTool({
|
||||
name: "probe_done",
|
||||
description:
|
||||
"Conclude the probing phase and hand off to the teaching phase. Call this ONCE, when the probe is " +
|
||||
"complete: every strand the lesson depends on is bracketed (a floor he reliably gets right AND a " +
|
||||
@@ -55,7 +56,7 @@ export function probeDoneTool(ctx: ProbeDoneContext): ToolSet {
|
||||
"(floor, ceiling, misconceptions, what to build from). It streams to the learner live and becomes " +
|
||||
"the handoff artifact the teacher plans from. Refused while a question is still open, and after " +
|
||||
"the probe is already concluded. After it succeeds, say your closing sentence and end your turn.",
|
||||
inputSchema: z.object({
|
||||
schema: z.object({
|
||||
assessment: z
|
||||
.object({
|
||||
strands: z
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
import { lhTool } from "../lh-tool.ts";
|
||||
import { Readability } from "@mozilla/readability";
|
||||
import { parseHTML } from "linkedom";
|
||||
import TurndownService from "turndown";
|
||||
@@ -137,11 +137,12 @@ async function fetchAndExtract(url: string): Promise<string> {
|
||||
// ── tool registry ────────────────────────────────────────────────────────────
|
||||
|
||||
export const webTools = {
|
||||
web_search: tool({
|
||||
web_search: lhTool({
|
||||
name: "web_search",
|
||||
description:
|
||||
"Search the web via Google Custom Search. One search per call — use one call per search angle. " +
|
||||
"Returns an array of results with title, url, and snippet.",
|
||||
inputSchema: z.object({
|
||||
schema: z.object({
|
||||
query: z.string().describe("The search query (a normal search string, phrases included)"),
|
||||
count: z.number().int().min(1).max(10).optional().describe("Number of results (default 5, max 10)"),
|
||||
}),
|
||||
@@ -161,11 +162,12 @@ export const webTools = {
|
||||
},
|
||||
}),
|
||||
|
||||
web_fetch: tool({
|
||||
web_fetch: lhTool({
|
||||
name: "web_fetch",
|
||||
description:
|
||||
"Fetch a web page and extract its readable content as markdown (Readability). " +
|
||||
"Handles HTML pages and plain text; JS-rendered pages fall back to a reader service.",
|
||||
inputSchema: z.object({
|
||||
schema: z.object({
|
||||
url: z.string().describe("The URL to fetch"),
|
||||
}),
|
||||
execute: async ({ url }): Promise<string> => {
|
||||
|
||||
Reference in New Issue
Block a user