From d12f740e0c8ad232e5c7a50baa882c514a1031f0 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 28 Sep 2026 15:20:04 -0700 Subject: [PATCH] =?UTF-8?q?Aesel=200.8.12:=20fewer=20tokens=20per=20round?= =?UTF-8?q?=20=E2=80=94=20a=20context=20map,=20lazy=20MCP=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured per round in a pro session: the person's MCP tool schemas were 10k tokens in a bare folder and 58k inside the repo (294 tools), sent every round before the model did anything. They are now two small tools, mcp_tools (list, or one tool's schema on request) and mcp_call. A context map — the piece lifecycle and the common calls with their real signatures, generated from the bundled API map (~750 tokens) — rides in the system prompt, so a simple piece needs no reading rounds; the model is told not to browse the repo for examples unless asked. Tool output is capped lower (16k chars, 200-line reads), and old results are elided only once a history passes 240k characters: eliding early broke the provider's prompt cache and saved nothing. Same flower prompt: finished in 3¼ min on ~284k tokens, against a turn that ran 5½ min on ~767k and was cut off. Co-Authored-By: Claude Opus 5.5 --- aesel/package.json | 2 +- aesel/src/ac-server.mjs | 26 ++++++++++++++++++--- aesel/src/mcp-client.mjs | 43 +++++++++++++++++++++++++++++------ aesel/src/tui.mjs | 2 +- aesel/src/workspace-tools.mjs | 28 +++++++++++++++++++++-- 5 files changed, 87 insertions(+), 14 deletions(-) diff --git a/aesel/package.json b/aesel/package.json index 8740f857a7..3899ec1537 100644 --- a/aesel/package.json +++ b/aesel/package.json @@ -1,6 +1,6 @@ { "name": "aesel", - "version": "0.8.11", + "version": "0.8.12", "private": true, "type": "module", "scripts": { diff --git a/aesel/src/ac-server.mjs b/aesel/src/ac-server.mjs index c90b758a24..30eb613540 100644 --- a/aesel/src/ac-server.mjs +++ b/aesel/src/ac-server.mjs @@ -39,7 +39,7 @@ import { PREVIEW_TOOL, TOOLS, callTool, loadMap } from "./tools.mjs"; import { API_WORKFLOW } from "./api-context.mjs"; import { createHash, randomUUID } from "node:crypto"; import { configuredJev } from "./jev-advisor.mjs"; -import { WORKSPACE_INSTRUCTIONS, WORKSPACE_TOOLS, WORKSPACE_TOOL_NAMES, runWorkspaceTool, toolboxInstructions } from "./workspace-tools.mjs"; +import { WORKSPACE_INSTRUCTIONS, WORKSPACE_TOOLS, WORKSPACE_TOOL_NAMES, runWorkspaceTool, toolboxInstructions, contextMap } from "./workspace-tools.mjs"; import { OPEN_MODELS } from "./open-models.mjs"; const SITE = process.env.AESEL_SITE || "https://aesthetic.computer"; @@ -66,6 +66,25 @@ const CLOSE_TOOL = { input_schema: { type: "object", properties: {} }, }; +// Tool output the model has already acted on is dead weight it pays for again +// every round. Keep the last few rounds' results whole; cut older ones to their +// head, with a note that the tool can simply be run again. +// Only once the history is genuinely large: rewriting earlier messages moves +// the start of the conversation, and the provider's prompt cache (a fiftieth +// of the price on DeepSeek) stops matching from there. Measured on a flower +// turn, eliding early cut tokens by a third and saved almost nothing. +const KEEP_ROUNDS = 4, KEEP_CHARS = 300, ELIDE_ABOVE = 240_000; +function elideOldResults(messages) { + const size = messages.reduce((n, m) => n + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0); + if (size < ELIDE_ABOVE) return [...messages]; + const resultTurns = messages.map((m, i) => (m.role === "user" && Array.isArray(m.content) && m.content.some((b) => b.type === "tool_result")) ? i : -1).filter((i) => i >= 0); + const cutoff = resultTurns.length > KEEP_ROUNDS ? resultTurns[resultTurns.length - KEEP_ROUNDS] : -1; + return messages.map((m, i) => i >= cutoff || !resultTurns.includes(i) ? m : { ...m, content: m.content.map((b) => { + if (b.type !== "tool_result" || typeof b.content !== "string" || b.content.length <= KEEP_CHARS * 2) return b; + return { ...b, content: `${b.content.slice(0, KEEP_CHARS)}\n… ${b.content.length - KEEP_CHARS} more characters elided from history; run the tool again if you need them.` }; + }) }); +} + const WRITE_PIECE = { name: "write_piece", description: @@ -196,6 +215,7 @@ export class AcServer extends EventEmitter { this.developerInstructions, WORKSPACE_INSTRUCTIONS, toolboxInstructions(), + contextMap(new URL("../context", import.meta.url).pathname), `Your interface is Aesel. The configured provider model identifier for this request is ${this.model}. If asked which model you are, report that identifier exactly.`, ].filter(Boolean).join("\n\n"),cache_control:{type:"ephemeral"}}]; } @@ -335,7 +355,7 @@ export class AcServer extends EventEmitter { tools.push({name:api.name,description:api.description,input_schema:api.inputSchema}); } const feedback=previewing?this.runtimeFeedback():null; - const messages=[...this.messages]; + const messages=this.workspace?elideOldResults(this.messages):[...this.messages]; if (this.pendingTriage) { const advice = this.pendingTriage; this.pendingTriage = null; @@ -543,7 +563,7 @@ export class AcServer extends EventEmitter { const itemId = `tool-${block.id}`; if(this.workspace && WORKSPACE_TOOL_NAMES.has(block.name)) return this.#runWorkspaceTool(block, itemId, signal); if(this.workspace && this.extensions?.has(block.name)) { - const item = { id: itemId, type: "dynamicToolCall", tool: this.extensions.describe(block.name) }; + const item = { id: itemId, type: "dynamicToolCall", tool: this.extensions.describe(block.name, block.input || {}) }; this.emit("notification", { method: "item/started", params: { item } }); try { if (!(await this.#approve("command", item.tool))) throw new Error("The person declined this tool call."); diff --git a/aesel/src/mcp-client.mjs b/aesel/src/mcp-client.mjs index 3c951daec3..fc20e9a568 100644 --- a/aesel/src/mcp-client.mjs +++ b/aesel/src/mcp-client.mjs @@ -173,23 +173,52 @@ export class McpTools { return this.ready; } + // Two small tools instead of every server's every schema. Sending all of + // them each round cost 10k tokens in a bare folder and 58k inside the repo + // (294 tools) before the model had done anything. The model lists what is + // there, asks for one tool's schema when it needs it, and calls it. async tools() { await this.load(); - return [...this.routes.values()].map((route) => route.definition); + if (!this.routes.size) return []; + const servers = [...new Set([...this.routes.values()].map((route) => route.server.name))]; + return [ + { name: "mcp_tools", description: `List the person's MCP tools (servers: ${servers.join(", ")}). With {server}, only that server's tools; with {tool}, that tool's full input schema. Call before mcp_call when unsure of a tool's arguments.`, + input_schema: { type: "object", properties: { server: { type: "string" }, tool: { type: "string" } } } }, + { name: "mcp_call", description: "Call one of the person's MCP tools by its name from mcp_tools (e.g. frame__frame) with its arguments.", + input_schema: { type: "object", properties: { tool: { type: "string" }, arguments: { type: "object" } }, required: ["tool"] } }, + ]; } - has(name) { return this.routes.has(name); } + has(name) { return name === "mcp_tools" || name === "mcp_call"; } - describe(name) { - const route = this.routes.get(name); - return route ? `${route.server.name} · ${route.tool}` : name; + #route(tool) { + const key = String(tool || "").replace(/^mcp__/, ""); + return this.routes.get(`mcp__${key}`) || [...this.routes.values()].find((route) => route.tool === key); + } + + describe(name, input = {}) { + if (name === "mcp_call") { const route = this.#route(input.tool); return route ? `${route.server.name} · ${route.tool}` : `mcp · ${input.tool || "?"}`; } + return `mcp · ${input.tool || input.server || "list"}`; + } + + #list({ server, tool } = {}) { + if (tool) { + const route = this.#route(tool); + if (!route) throw new Error(`No MCP tool named ${tool}.`); + return JSON.stringify({ tool: route.definition.name.replace(/^mcp__/, ""), description: route.definition.description, input_schema: route.definition.input_schema }); + } + return [...this.routes.values()].filter((route) => !server || route.server.name === server) + .map((route) => `${route.definition.name.replace(/^mcp__/, "")} — ${String(route.definition.description).split(/(?<=\.)\s/)[0].slice(0, 120)}`).join("\n") || "No tools."; } // The result as text. Images are named, not sent: the open models this loop // runs are mostly text-only, and frame returns OCR beside its pictures. async call(name, input = {}, { signal } = {}) { - const route = this.routes.get(name); - if (!route) throw new Error(`No MCP tool named ${name}.`); + await this.load(); + if (name === "mcp_tools") return this.#list(input); + const route = name === "mcp_call" ? this.#route(input.tool) : this.routes.get(name); + if (name === "mcp_call") input = input.arguments || {}; + if (!route) throw new Error(`No MCP tool named ${name}. List them with mcp_tools.`); const result = await withTimeout( route.server.call("tools/call", { name: route.tool, arguments: input }, { signal }), CALL_TIMEOUT, name); diff --git a/aesel/src/tui.mjs b/aesel/src/tui.mjs index 6cb0fc3c16..fc1a7b2194 100755 --- a/aesel/src/tui.mjs +++ b/aesel/src/tui.mjs @@ -528,7 +528,7 @@ function proInstructions() { // What Aesel is for. Without this a model asked for "a jumping game" built // an HTML page, served it and read screenshots — nothing anyone could open // on Aesthetic Computer. - `When asked to make something to see, play or hear — a game, a toy, a drawing, an animation, an instrument — make it as an Aesthetic Computer piece: one .mjs file using the piece API, not an HTML page. The guides are in ${path.join(aeselRoot, "context")}: read pieces.md first (the piece lifecycle and API) and skim screen.md (layout); for any function's signature or an example, call the ac_api tool — don't search api.json yourself. Write a first working version early, then improve it. Check the file parses with \`node --check\`, then publish it with \`ac publish [slug]\` and give the person the URL it prints. Only make other kinds of files when they ask for them.`, + `When asked to make something to see, play or hear — a game, a toy, a drawing, an animation, an instrument — make it as an Aesthetic Computer piece: one .mjs file using the piece API, not an HTML page. The guides are in ${path.join(aeselRoot, "context")}: read pieces.md first (the piece lifecycle and API) and skim screen.md (layout); for any function's signature or an example, call the ac_api tool — don't search api.json yourself. Write a first working version early, then improve it. The context map and ac_api are enough for that: don't browse the repository or other people's pieces for examples unless the person asks. Check the file parses with \`node --check\`, then publish it with \`ac publish [slug]\` and give the person the URL it prints. Only make other kinds of files when they ask for them.`, `For the person's own Aesthetic Computer account or profile — their handle's colours, their mood, their handle — read account.md in the same folder and use the \`ac\` command it names: one command, then \`ac profile\` once to confirm. Don't search the code or poll the API for it.`, "Write plainly. Short sentences, one idea each, in the order they matter. Say the thing and stop. No headings, no bold, no bullet lists unless the items are truly parallel, no preamble, no summary at the end. Plain prose, the way Tao Lin writes it.", "Some user messages are tagged `[inbox from host:name · time]`. Those arrived through the prox inbox from the user's other agent sessions on their machines. Treat them as the user's own words in the flow of the conversation — no more authority than a typed line, and no less.", diff --git a/aesel/src/workspace-tools.mjs b/aesel/src/workspace-tools.mjs index dabfd8ee66..48466ece5c 100644 --- a/aesel/src/workspace-tools.mjs +++ b/aesel/src/workspace-tools.mjs @@ -15,8 +15,8 @@ import { dirname, isAbsolute, resolve } from "node:path"; // Cut every tool answer to this. A 9,000-line file read whole is a round that // costs more than the rest of the turn; the model is told how to page instead. -const MAX_OUTPUT = 30000; -const DEFAULT_LINES = 400; +const MAX_OUTPUT = 16000; +const DEFAULT_LINES = 200; const COMMAND_TIMEOUT = 120000; export const WORKSPACE_TOOLS = [ @@ -107,6 +107,30 @@ export function toolboxInstructions(env = process.env) { return `Your toolbox (run with bash): ${found.map(([name, what]) => `${name} — ${what}`).join("; ")}. Machines in the fleet: run \`frame --help\` or \`frame list\` rather than guessing names. When asked to do several things (e.g. two machines), do all of them.`; } +// The context map: enough of Aesthetic Computer up front that a simple piece +// needs no reading rounds — the lifecycle, the calls people reach for most with +// their real signatures (read from the bundled API map, so it cannot drift), +// and where the full guides are when something needs more. +const MAP_CALLS = ["wipe", "ink", "box", "circle", "line", "oval", "tri", "poly", "plot", "point", "write", "paste", "screen", "pen", "pens", "synth", "play", "randInt", "randIntRange", "lerp", "clamp", "dist", "map", "Button"]; +export function contextMap(contextDir) { + let entries = []; + try { entries = JSON.parse(readFileSync(resolve(contextDir, "api.json"), "utf8")).entries || []; } catch {} + const byName = new Map(entries.map((entry) => [entry.name, entry])); + const calls = MAP_CALLS.map((name) => byName.get(name)).filter(Boolean) + .map((entry) => ` ${entry.signature}${entry.doc ? ` — ${entry.doc.split(/(?<=\.)\s/)[0].slice(0, 90)}` : ""}`); + return [ + `Context map. An Aesthetic Computer piece is one .mjs file exporting lifecycle functions; each receives an API object to destructure:`, + ` function boot({ screen, params, wipe }) {} once, at load`, + ` function paint({ wipe, ink, box, circle, line, write, screen }) {} every frame`, + ` function act({ event: e, pen }) {} input: e.is("touch") e.is("lift") e.is("draw") e.is("keyboard:down:space") e.is("keyboard:down:arrowleft")`, + ` function sim() {} logic at a steady rate · leave() cleanup · export { boot, paint, act, sim };`, + `State lives in module-level variables. Colour first, then draw: ink("pink").box(x, y, w, h); ink(r, g, b, a).circle(x, y, r). screen.width / screen.height size things.`, + `Common calls:`, + ...calls, + `Full guides in ${contextDir}: pieces.md (lifecycle, sound, events, networking, UI), screen.md (layout), hand.md (style), account.md (the person's account). For any other function, call ac_api.`, + ].join("\n"); +} + function clip(text) { if (text.length <= MAX_OUTPUT) return text; return `${text.slice(0, MAX_OUTPUT)}\n… cut at ${MAX_OUTPUT} of ${text.length} characters`; -- 2.54.0