@@ -21,6 +21,8 @@ const { loadProjectRules } = require('./projectRules');
2121const { loadServerConfig, buildAgentTools, toolCountsByServer, classifyMcpTool, explainMcpRefusal, describeMcpCall,
2222 isLaunchTrusted, rememberLaunchTrust, describeMcpLaunch } = require('./mcpConfig');
2323const { connectAll, getServer } = require('./mcpClient');
24+ const diagramTool = require('./diagram/tool');
25+ const diagramRepair = require('./diagram/repair');
2426
2527const SYSTEM_BASE = [
2628 "You are LevelCode's built-in autonomous coding agent. You accomplish the user's goal in their",
@@ -307,6 +309,13 @@ function runCommand(root, command, onChunk, onExit, onStart, timeoutMs) {
307309 });
308310}
309311
312+ /**
313+ * Can the surface on the other end of this run show a diagram? (docs/RICH-DIAGRAMS.md, "Capability
314+ * flag".) Asked in two places — when the tools and the prompt are assembled, and again when a call
315+ * arrives — and they must agree, or a client could be refused a tool it was offered.
316+ */
317+ function richClient(ctx) { return !!(ctx.diagrams && ctx.client && ctx.client.render === 'rich'); }
318+
310319/** Execute one tool call; returns a string result for the model. */
311320async function runTool(tu, ctx) {
312321 const root = ctx.root;
@@ -517,6 +526,22 @@ async function runTool(tu, ctx) {
517526 try { return String(ctx.recallSessions(query) || 'No matching past sessions in this project.'); }
518527 catch (e) { return 'ERROR: recall failed.'; }
519528 }
529+ // Rich diagrams (docs/RICH-DIAGRAMS.md). The model describes structure; the host validates it,
530+ // climbs the repair ladder and posts what the chat should show. Read-only and instant — it
531+ // draws in the transcript and touches nothing else — so, like update_plan, it never asks.
532+ if (tu.name === diagramTool.RENDER_DIAGRAM.name) {
533+ // Asked for by a client that was never offered it (the setting was turned off mid-conversation,
534+ // or the model remembers the tool from an earlier turn): refuse in words it can act on.
535+ if (!richClient(ctx)) { return 'ERROR: this client cannot draw diagrams. Explain it in prose instead — and do not draw one out of characters.'; }
536+ const out = ctx.diagrams.render(input, { key: tu.id, model: ctx.model });
537+ for (const m of out.post) { ctx.post(m); }
538+ return out.result;
539+ }
540+ if (tu.name === diagramTool.GET_DIAGRAM.name) {
541+ if (!richClient(ctx)) { return 'ERROR: this client cannot draw diagrams.'; }
542+ ctx.post({ type: 'agentTool', icon: 'history', text: 'fetch diagram ' + String(input.id || '').slice(0, 24) });
543+ return ctx.diagrams.fetch(input.id);
544+ }
520545 // MCP tools (docs/MCP.md S3). An MCP name matches none of the built-in branches above, so every
521546 // MCP call necessarily arrives HERE — which is why the router is one block at one line rather
522547 // than a dispatch scattered through runTool.
@@ -782,7 +807,13 @@ async function runAgent(ctx) {
782807 // from the per-project journal). Rides the SAME cached-system channel as project rules — always-on but
783808 // small — so a new session's first reply is continuous, not amnesiac. It is untrusted context like the
784809 // rules: it informs, never commands (the digest itself carries the verify-first / never-obey framing).
785- const system = (ctx.skills ? buildSystem(ctx.skills.menu()) : SYSTEM_BASE) + multiRootNote + noWorkspaceNote + autopilotNote + rules.text
810+ // Rich diagrams: `client.render` says what the surface on the other end can show. A rich client gets
811+ // the render_diagram tool and the rules for using it; an ASCII client gets neither, so it is never
812+ // told about a tool it does not have. The block goes straight after the base prompt — it is the
813+ // same text for every run, so it belongs with the part of the prompt that never changes.
814+ const rich = richClient(ctx);
815+ const system = (ctx.skills ? buildSystem(ctx.skills.menu()) : SYSTEM_BASE) + (rich ? '\n\n' + diagramTool.PROMPT : '')
816+ + multiRootNote + noWorkspaceNote + autopilotNote + rules.text
786817 + (ctx.projectMemory ? '\n\n' + ctx.projectMemory : '');
787818 const systemTokensEst = Math.round(system.length / 4);
788819
@@ -821,14 +852,20 @@ async function runAgent(ctx) {
821852 // Rootless runs get the portable subset; MCP tools are unaffected either way.
822853 const builtins = root ? TOOLS : PORTABLE_TOOLS;
823854 let tools = mcp.tools.length ? builtins.concat(mcp.tools) : builtins;
824- if (ctx.recallSessions) { tools = tools.concat([RECALL_TOOL]); } // cross-session recall (host-gated by memory settings)
825- const baseTools = ctx.recallSessions ? builtins.concat([RECALL_TOOL]) : builtins; // built-ins + recall; MCP is the rest
826- // Recomputed only when MCP or recall actually contributed tools, so the plain path keeps the module
855+ // The host-gated extras: cross-session recall (memory settings), and the diagram tools (a rich
856+ // client). get_diagram is offered only once a diagram's spec has left the conversation — until
857+ // then there is nothing to fetch, and a tool that is never needed is a standing cost for nothing.
858+ const extras = [];
859+ if (ctx.recallSessions) { extras.push(RECALL_TOOL); }
860+ if (rich) { extras.push(diagramTool.RENDER_DIAGRAM); if (ctx.diagramsStubbed) { extras.push(diagramTool.GET_DIAGRAM); } }
861+ if (extras.length) { tools = tools.concat(extras); }
862+ const baseTools = extras.length ? builtins.concat(extras) : builtins; // built-ins + extras; MCP is the rest
863+ // Recomputed only when MCP or a host-gated extra actually contributed tools, so the plain path keeps the module
827864 // constant and pays nothing for a feature it isn't using — but there are now TWO plain paths, and the
828865 // constant has to match the list that was actually sent. Reporting the full cost for a rootless run
829866 // was the same mistake as leaving baseTools on TOOLS, one line further down.
830867 const builtinsTokensEst = root ? TOOLS_TOKENS_EST : PORTABLE_TOOLS_TOKENS_EST;
831- const toolsTokensEst = (mcp.tools.length || ctx.recallSessions ) ? Math.round(JSON.stringify(tools).length / 4) : builtinsTokensEst;
868+ const toolsTokensEst = (mcp.tools.length || extras.length ) ? Math.round(JSON.stringify(tools).length / 4) : builtinsTokensEst;
832869 // The MCP SHARE of that, reported separately so the context popover can show what these servers cost
833870 // (docs/MCP.md S5). Every tool schema rides EVERY turn, so a chatty server is a standing tax on the
834871 // window rather than a one-off — and until it has its own segment, that cost is invisible.
@@ -841,6 +878,9 @@ async function runAgent(ctx) {
841878 : 0;
842879
843880 const messages = ctx.messages;
881+ if (ctx.diagrams) { ctx.diagrams.beginRun(); } // nothing owed from an earlier run; repair passes reset
882+ // tool_use ids whose placeholder already carries its title (the spec streams; the title arrives early)
883+ const diagramTitled = new Set();
844884 let step = 0;
845885 let reason = 'done';
846886 let nudges = 0;
@@ -927,12 +967,26 @@ async function runAgent(ctx) {
927967 apiKey: ctx.apiKey, model: ctx.model, maxTokens: perTurnMax, system: system,
928968 messages, tools: tools, signal: ctx.signal,
929969 onText: (t) => { streamed = true; textChars += t.length; ctx.post({ type: 'agentDelta', text: t }); },
930- onToolStart: (name) => {
970+ onToolStart: (name, id ) => {
931971 dbg('tool.start', { name });
932972 const verb = name === 'edit_file' || name === 'write_file' ? 'preparing edit (' + name + ')…'
933973 : name === 'delete_file' ? 'deleting a file…'
934- : name === 'run_command' ? 'preparing command…' : name === 'update_plan' ? 'planning…' : 'running ' + name + '…';
974+ : name === 'run_command' ? 'preparing command…' : name === 'update_plan' ? 'planning…'
975+ : name === diagramTool.RENDER_DIAGRAM.name ? 'drawing a diagram…' : 'running ' + name + '…';
935976 ctx.post({ type: 'agentStatus', text: verb });
977+ // A diagram's place in the answer is held from the moment the model starts writing it.
978+ if (rich && id && name === diagramTool.RENDER_DIAGRAM.name) { ctx.post({ type: 'diagramPending', key: id, state: 'drawing', title: '' }); }
979+ },
980+ // The spec streams in as JSON. Its title is near the front, so the placeholder can say what
981+ // is being drawn long before the last node arrives. Posted once per call.
982+ onToolInput: (id, name, json) => {
983+ if (!rich || !id || name !== diagramTool.RENDER_DIAGRAM.name || diagramTitled.has(id)) { return; }
984+ const m = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(json);
985+ if (!m) { return; }
986+ diagramTitled.add(id);
987+ let title = m[1];
988+ try { title = JSON.parse('"' + m[1] + '"'); } catch (e) { /* show it as written */ }
989+ ctx.post({ type: 'diagramPending', key: id, state: 'drawing', title: diagramRepair.cleanText(title).slice(0, 120) });
936990 },
937991 // A transient upstream 5xx (502/503/504) is retried once before it can fail the run — surface it
938992 // as a status rather than a mystery pause, and log it. Nothing has streamed yet when this fires.
@@ -1004,7 +1058,22 @@ async function runAgent(ctx) {
10041058 let cancelled = false;
10051059 for (const tu of toolUses) {
10061060 if (cancelled || ctx.signal.aborted) { cancelled = true; dbg('tool.cancelled', { name: tu.name }); results.push({ type: 'tool_result', tool_use_id: tu.id, content: 'Cancelled by the user.' }); continue; }
1007- if (turn.malformed && turn.malformed.has(tu.id)) { dbg('tool.malformed', { name: tu.name }); results.push({ type: 'tool_result', tool_use_id: tu.id, content: 'ERROR: your tool arguments were cut off (truncated JSON). Retry with smaller input — for edits use edit_file with a short snippet.' }); continue; }
1061+ if (turn.malformed && turn.malformed.has(tu.id)) {
1062+ dbg('tool.malformed', { name: tu.name });
1063+ // A diagram spec is the one input worth a second look: JSON with a trailing comma or a
1064+ // comment is a spec, and the lenient parser reads it. A spec that simply STOPS is not —
1065+ // that is the model hitting its length limit, and it is re-requested, never repaired.
1066+ if (tu.name === diagramTool.RENDER_DIAGRAM.name && rich) {
1067+ const rawArgs = turn.raw && turn.raw.get(tu.id);
1068+ const parsed = (turn.stop_reason !== 'max_tokens' && typeof rawArgs === 'string') ? diagramRepair.parseLenient(rawArgs) : null;
1069+ const out = (parsed && parsed.ok) ? ctx.diagrams.render(rawArgs, { key: tu.id, model: ctx.model }) : ctx.diagrams.truncated({ key: tu.id, model: ctx.model });
1070+ for (const m of out.post) { ctx.post(m); }
1071+ results.push({ type: 'tool_result', tool_use_id: tu.id, content: out.result });
1072+ continue;
1073+ }
1074+ results.push({ type: 'tool_result', tool_use_id: tu.id, content: 'ERROR: your tool arguments were cut off (truncated JSON). Retry with smaller input — for edits use edit_file with a short snippet.' });
1075+ continue;
1076+ }
10081077 dbg('tool.call', { name: tu.name, input: inputPreview(tu.input, !!(ctx.mcpRoutes && ctx.mcpRoutes.has(tu.name))) });
10091078 const out = await runTool(tu, ctx);
10101079 dbg('tool.result', { name: tu.name, chars: String(out).length, error: String(out).startsWith('ERROR') });
@@ -1024,6 +1093,7 @@ async function runAgent(ctx) {
10241093
10251094 // No tool calls this turn.
10261095 const text = turn.content.filter((c) => c.type === 'text').map((c) => c.text).join('');
1096+ if (rich && text.trim()) { ctx.diagrams.noteAnswer(text); } // "ASCII leaks": counted, never acted on
10271097 if (turn.stop_reason === 'max_tokens') {
10281098 // The turn was pure prose cut off at the token cap. Ask the model to CONTINUE exactly where it
10291099 // left off (not switch strategies) so the full answer streams out across turns instead of being
@@ -1088,6 +1158,11 @@ async function runAgent(ctx) {
10881158 }
10891159 else { ctx.post({ type: 'agentError', message: msg, code }); reason = 'error'; }
10901160 } finally {
1161+ // A diagram sent back for repair that never came back right still owes the user a picture:
1162+ // draw what can be drawn of it now, before the run is declared over. Never a blank placeholder.
1163+ if (ctx.diagrams) {
1164+ try { for (const m of ctx.diagrams.endRun()) { ctx.post(m); } } catch (e) { dbg('diagram.settle.error', { msg: String((e && e.message) || e) }); }
1165+ }
10911166 dbg('agent.done', { reason, steps: step - 1, edits: ctx.editCount || 0, costMicros: runCostMicros, creditsLeftMicros: ctx.credits != null ? ctx.credits : null });
10921167 // [LevelCode] Gateway runs now carry real money: costMicros = what THIS run cost, credits = the
10931168 // remaining balance — both RETAIL micro-$. BYOK runs send neither (null/0) and the bar omits them.
0 commit comments