2026-09-12 02:34:18 +03:00
import type { AssistantMessage , Message , ToolResultMessage , Usage } from "@earendil-works/pi-ai" ;
2026-08-22 10:24:02 +03:00
import { z } from "zod" ;
2026-09-12 02:34:18 +03:00
import { lhTool , piToolDeclarations , type LhTool } from "../lh-tool.ts" ;
import { piEngineFor , thinkingLevelFor , type PiEngine } from "../models.ts" ;
import type { VisualResult } from "../../core/contracts.ts" ;
2026-08-22 10:24:02 +03:00
import { getHeadless , type HeadlessApi } from "./headless.ts" ;
/**
* The visual-maker sub-agent (vision §2.5): authors ONE precise SVG from a
* brief, renders it to a PNG in the SAME headless Chrome, LOOKS at the
* result, and iterates until correct — a direct port of the vault POC's
* svg-maker doctrine ("the one rule that matters: verify by looking").
*
* LOOP SHAPE (decided in the W6 brief): hand-rolled with generateText + a
* messages array, because the loop must feed an IMAGE back to the model after
* each render. write_svg / render_svg are declared WITHOUT execute — when the
* model calls them generateText stops with the tool calls, and this loop
* appends the tool results itself, followed by a user message containing the
* rendered PNG as a file part ("Here is the current render"). ToolLoopAgent's
* execute-tool plumbing can't express that image-in-the-loop shape cleanly.
*
* Doctrine enforcement in code: if the model tries to finish while the
* current SVG has never been rendered (or changed since its last render), the
* loop renders it anyway, shows the image, and asks the model to look before
* finishing. Nothing is delivered without having been looked at.
*
* Failure semantics mirror the demo-builder: { svg: "", notes: "FAILED: …" }.
*/
export interface VisualMakerInput {
brief : string ;
}
export interface VisualMakerOptions {
2026-09-12 02:34:18 +03:00
/** Test seam: scripted engine; production uses the visualMaker role. */
engine? : PiEngine ;
2026-08-22 10:24:02 +03:00
headless? : HeadlessApi ;
/** Max model rounds (write/render cycles + final answer). Default 8. */
maxIterations? : number ;
/** Live activity-line callback (updates the job record). */
onActivity ?: ( line : string ) => void ;
2026-09-11 03:25:09 +03:00
/** Kills the run mid-model-call (2026-09 real-kill abort semantics). */
abortSignal? : AbortSignal ;
2026-09-12 02:34:18 +03:00
/** Money channel: one call per loop iteration (streamSimple), folded
2026-09-11 03:25:09 +03:00
* into JobRecord.usage. */
2026-09-12 02:34:18 +03:00
onUsage ?: ( usage : Usage ) => void ;
2026-08-22 10:24:02 +03:00
}
const INSTRUCTIONS = `You are a visual maker — a diagram author + renderer for precise, spatial figures. You
receive a brief describing ONE idea that needs exact placement (coordinate geometry, number lines,
vectors, function plots, physical layouts — things auto-layout diagrams cannot express), and you return
ONE clean, correct SVG. You never see the lesson; the brief is everything you know.
You do NOT decide what idea to show — the teacher already decided. Your job is faithful, precise
composition, and above everything CORRECTNESS: the picture must not assert anything false. A right-angle
mark on the wrong corner, a vector pointing the wrong way, a point plotted at the wrong coordinate is a
failure even if it renders cleanly.
2026-09-01 19:15:43 +03:00
Your three tools:
2026-08-22 10:24:02 +03:00
- write_svg({ svg }) — write the complete <svg>…</svg> source (overwrites the previous version).
2026-09-01 19:15:43 +03:00
- edit_svg({ old_text, new_text }) — a surgical exact-match replacement in the current SVG:
old_text must appear EXACTLY ONCE; it is replaced by new_text. Call write_svg first. For anything
beyond a one-spot fix (new elements, restructured layout), write_svg the whole document again.
2026-08-22 10:24:02 +03:00
- render_svg({}) — rasterize the current SVG in headless Chrome. The render arrives as an image in the
message right after the tool result.
THE ONE RULE THAT MATTERS MOST: verify by looking. You are done only when you have LOOKED at the
rendered PNG and confirmed it is true to the brief. Rendering success only proves the SVG parsed; it
says nothing about whether the geometry is right or the picture is readable. When you look:
- Is every coordinate, angle, direction, and proportion actually correct? Re-derive the geometry if unsure.
- Are labels placed clearly, not overlapping lines or each other?
- Is anything clipped by the viewBox, too small to read, or cramped?
- Would a learner instantly read the intended idea from this picture alone?
Workflow: plan the coordinate space (viewBox + margins, nothing touching the edges) → write_svg →
2026-09-01 19:15:43 +03:00
render_svg → LOOK critically → fix (edit_svg for one-spot fixes, write_svg otherwise) and re-render
until correct and clean. Never finish without having looked at a render of your FINAL SVG.
2026-08-22 10:24:02 +03:00
SVG requirements:
- A complete <svg> with explicit width/height AND a matching viewBox.
- White background (<rect> or style), dark strokes, sans-serif labels at generous sizes.
- One idea, fewest elements — sparse and large beats busy and tiny.
- Draw only what the brief specifies; never invent data, values, or shapes to fill space.
- Plain, clean styling: at most one accent color. An explanatory diagram, not art.
Your FINAL message: 1-2 sentences of notes for the teacher (what the figure shows, any caveats) —
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.` ;
2026-09-12 02:34:18 +03:00
export function createVisualTools () : Record < string , LhTool < any , any >> {
2026-08-22 10:24:02 +03:00
return {
2026-09-12 02:34:18 +03:00
write_svg : lhTool ({
name : "write_svg" ,
2026-08-22 10:24:02 +03:00
description : "Write the complete SVG source for the figure (overwrites any previous version)." ,
2026-09-12 02:34:18 +03:00
schema : z.object ({
2026-08-22 10:24:02 +03:00
svg : z.string (). describe ( 'Complete <svg xmlns="…">…</svg> markup.' ),
}),
}),
2026-09-12 02:34:18 +03:00
edit_svg : lhTool ({
name : "edit_svg" ,
2026-09-01 19:15:43 +03:00
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." ,
2026-09-12 02:34:18 +03:00
schema : z.object ({
2026-09-01 19:15:43 +03:00
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." ),
2026-09-12 02:34:18 +03:00
}),
2026-09-01 19:15:43 +03:00
}),
2026-09-12 02:34:18 +03:00
render_svg : lhTool ({
name : "render_svg" ,
2026-08-22 10:24:02 +03:00
description : "Render the current SVG to a PNG and show it to you for inspection. No arguments." ,
2026-09-12 02:34:18 +03:00
schema : z.object ({}),
2026-08-22 10:24:02 +03:00
}),
};
}
const LOOK_MESSAGE =
"Here is the current render of your SVG — inspect it critically (geometry, labels, clipping, readability):" ;
const UNLOOKED_FINISH_MESSAGE =
"You finished without looking at a render of your final SVG. Above is its render. If it is correct and clean, reply with just your notes; otherwise call write_svg with fixes and render again." ;
export async function runVisualMaker ( input : VisualMakerInput , opts : VisualMakerOptions = {}) : Promise < VisualResult > {
const headless = opts . headless ?? getHeadless ();
const brief = input . brief . trim () || "(no brief)" ;
const maxIterations = opts . maxIterations ?? 8 ;
const reason = headless . unavailableReason ();
if ( reason ) {
return { svg : "" , notes : `FAILED: ${ reason } ` };
}
const tools = createVisualTools ();
2026-09-12 02:34:18 +03:00
const toolDeclarations = piToolDeclarations ( tools );
const engine = opts . engine ?? piEngineFor ( "visualMaker" );
const reasoning = thinkingLevelFor ( "visualMaker" );
2026-08-22 10:24:02 +03:00
const prompt = [
"Brief for ONE precise figure (from the teacher, verbatim — it is everything you know):" ,
brief ,
"" ,
"Author the SVG, render it, look at it, iterate until correct, then finish with your notes." ,
]. join ( "\n" );
2026-09-12 02:34:18 +03:00
let messages : Message [] = [{ role : "user" , content : prompt , timestamp : Date.now () }];
2026-08-22 10:24:02 +03:00
let currentSvg : string | null = null ;
/** The svg shown in the most recent render (null = never rendered). */
let renderedSvg : string | null = null ;
let finalText = "" ;
try {
for ( let iteration = 0 ; iteration < maxIterations ; iteration ++ ) {
2026-09-12 02:34:18 +03:00
// 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 );
2026-09-11 03:25:09 +03:00
// Money channel: each iteration is one model call.
opts . onUsage ? .( result . usage );
2026-09-12 02:34:18 +03:00
messages = [... messages , result ];
2026-08-22 10:24:02 +03:00
2026-09-12 02:34:18 +03:00
const toolCalls = result . content . filter (( c ) : c is Extract < typeof c , { type : "toolCall" }> => c . type === "toolCall" );
2026-08-22 10:24:02 +03:00
if ( toolCalls . length === 0 ) {
2026-09-12 02:34:18 +03:00
finalText = result . content
. filter (( c ) : c is { type : "text" ; text : string } => c . type === "text" )
. map (( c ) => c . text )
. join ( "" )
. trim ();
2026-08-22 10:24:02 +03:00
// 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.
if ( currentSvg && renderedSvg !== currentSvg ) {
let png : Buffer ;
try {
png = await headless . renderSvg ( currentSvg );
} catch ( err ) {
const msg = err instanceof Error ? err.message : String ( err );
return { svg : "" , notes : `FAILED: rendering the final SVG failed — ${ msg } ` };
}
opts . onActivity ? .( "render_svg: inspecting final render" );
renderedSvg = currentSvg ;
messages = [
... messages ,
2026-09-12 02:34:18 +03:00
{
role : "user" ,
content : [
{ type : "text" , text : UNLOOKED_FINISH_MESSAGE },
{ type : "image" , data : png.toString ( "base64" ), mimeType : "image/png" },
],
timestamp : Date.now (),
},
2026-08-22 10:24:02 +03:00
];
continue ;
}
return finish ( currentSvg , finalText );
}
2026-09-12 02:34:18 +03:00
const toolResults : ToolResultMessage [] = [];
2026-08-22 10:24:02 +03:00
let renderPng : Buffer | null = null ; // appended as the image message AFTER the tool results
for ( const call of toolCalls ) {
2026-09-12 02:34:18 +03:00
if ( call . name === "write_svg" ) {
const svg = ( call . arguments as { svg? : unknown }). svg ;
2026-08-22 10:24:02 +03:00
if ( typeof svg !== "string" || ! svg . includes ( "<svg" )) {
2026-09-12 02:34:18 +03:00
toolResults . push ( toolText ( call . id , call . name , "ERROR: expected a complete <svg>…</svg> document in the `svg` field." ));
2026-08-22 10:24:02 +03:00
continue ;
}
currentSvg = svg ;
opts . onActivity ? .( `write_svg: ${ ( svg . length / 1024 ). toFixed ( 1 ) } KB` );
2026-09-12 02:34:18 +03:00
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 };
2026-09-01 19:15:43 +03:00
if ( ! currentSvg ) {
2026-09-12 02:34:18 +03:00
toolResults . push ( toolText ( call . id , call . name , "ERROR: nothing to edit — call write_svg first." ));
2026-09-01 19:15:43 +03:00
continue ;
}
if ( typeof edit . old_text !== "string" || typeof edit . new_text !== "string" || ! edit . old_text ) {
2026-09-12 02:34:18 +03:00
toolResults . push ( toolText ( call . id , call . name , "ERROR: expected old_text (non-empty) and new_text strings." ));
2026-09-01 19:15:43 +03:00
continue ;
}
const occurrences = currentSvg . split ( edit . old_text ). length - 1 ;
if ( occurrences === 0 ) {
toolResults . push (
toolText (
2026-09-12 02:34:18 +03:00
call . id ,
call . name ,
2026-09-01 19:15:43 +03:00
`ERROR: old_text not found. Closest line for reference: \ n ${ snippetAround ( currentSvg , edit . old_text ) } ` ,
),
);
continue ;
}
if ( occurrences > 1 ) {
toolResults . push (
2026-09-12 02:34:18 +03:00
toolText ( call . id , call . name , `ERROR: old_text appears ${ occurrences } times — it must match EXACTLY ONCE. Include more surrounding context to make it unique.` ),
2026-09-01 19:15:43 +03:00
);
continue ;
}
currentSvg = currentSvg . replace ( edit . old_text , edit . new_text );
opts . onActivity ? .( "edit_svg: applied" );
2026-09-12 02:34:18 +03:00
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" ) {
2026-08-22 10:24:02 +03:00
if ( ! currentSvg ) {
2026-09-12 02:34:18 +03:00
toolResults . push ( toolText ( call . id , call . name , "ERROR: nothing to render — call write_svg first." ));
2026-08-22 10:24:02 +03:00
continue ;
}
try {
const png = await headless . renderSvg ( currentSvg );
renderPng = png ;
renderedSvg = currentSvg ;
opts . onActivity ? .( "render_svg: inspecting render" );
2026-09-12 02:34:18 +03:00
toolResults . push ( toolText ( call . id , call . name , "Rendered — the PNG follows in the next message. Look at it." ));
2026-08-22 10:24:02 +03:00
} catch ( err ) {
const msg = err instanceof Error ? err.message : String ( err );
opts . onActivity ? .( "render_svg: render error" );
2026-09-12 02:34:18 +03:00
toolResults . push ( toolText ( call . id , call . name , `RENDER ERROR (the SVG may be malformed): ${ msg } . Fix it with write_svg and render again.` ));
2026-08-22 10:24:02 +03:00
}
} else {
2026-09-12 02:34:18 +03:00
toolResults . push ( toolText ( call . id , call . name , `ERROR: unknown tool " ${ call . name } ". Use write_svg, edit_svg, or render_svg.` ));
2026-08-22 10:24:02 +03:00
}
}
// Message order: assistant tool-calls → tool results → render image.
2026-09-12 02:34:18 +03:00
messages = [... messages , ... toolResults ];
2026-08-22 10:24:02 +03:00
if ( renderPng ) {
messages = [
... messages ,
{
role : "user" ,
content : [
{ type : "text" , text : LOOK_MESSAGE },
2026-09-12 02:34:18 +03:00
{ type : "image" , data : renderPng.toString ( "base64" ), mimeType : "image/png" },
2026-08-22 10:24:02 +03:00
],
2026-09-12 02:34:18 +03:00
timestamp : Date.now (),
2026-08-22 10:24:02 +03:00
},
];
}
}
} catch ( err ) {
const msg = err instanceof Error ? err.message : String ( err );
return { svg : "" , notes : `FAILED: the maker crashed — ${ msg } ` };
}
// Iteration limit: deliver the last written SVG only if it was looked at.
if ( currentSvg && renderedSvg === currentSvg ) {
return { svg : currentSvg , notes : finalText || "Iteration limit reached — delivering the last verified SVG." };
}
return { svg : "" , notes : `FAILED: iteration limit ( ${ maxIterations } ) reached without a verified SVG.` };
}
2026-09-12 02:34:18 +03:00
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" );
2026-08-22 10:24:02 +03:00
}
2026-09-01 19:15:43 +03:00
/** A 3-line numbered context window around the closest fuzzy match for a
* failed edit — enough for the model to see what went wrong and retry
* (the pi svg_tools snippetAround semantics). */
function snippetAround ( svg : string , target : string ) : string {
const words = target . trim (). split ( /\s+/ ). filter ( Boolean );
if ( words . length === 0 ) return "(empty old_text)" ;
const lines = svg . split ( "\n" );
let bestAt = - 1 ;
let bestScore = - 1 ;
for ( let i = 0 ; i < lines . length ; i ++ ) {
const score = words . filter (( w ) => lines [ i ] ! . includes ( w )). length ;
if ( score > bestScore ) {
bestScore = score ;
bestAt = i ;
}
}
if ( bestAt < 0 ) return "(no similar line found)" ;
const from = Math . max ( 0 , bestAt - 1 );
const to = Math . min ( lines . length , bestAt + 2 );
return lines
. slice ( from , to )
. map (( line , idx ) => ` ${ from + idx + 1 } | ${ line } ` )
. join ( "\n" );
}
2026-08-22 10:24:02 +03:00
function finish ( svg : string | null , finalText : string ) : VisualResult {
if ( ! svg ) {
return {
svg : "" ,
notes : `FAILED: the maker never wrote an SVG. ${ finalText ? ` ( ${ finalText . slice ( 0 , 300 ) } )` : "" } ` ,
};
}
return { svg , notes : finalText || undefined };
}