import { Agent } from "@earendil-works/pi-agent-core"; import { createAssistantMessageEventStream, Type } from "@earendil-works/pi-ai"; import { env, pipeline } from "@huggingface/transformers"; const LOCAL_MODELS = { qwen25coder: { id: "onnx-community/Qwen2.5-Coder-0.5B-Instruct", name: "Qwen2.5 Coder 0.5B ONNX", promptStyle: "qwen", }, qwen: { id: "onnx-community/Qwen3-0.6B-ONNX", name: "Qwen3 0.6B ONNX", promptStyle: "qwen", }, minicpm: { id: "Mike0021/MiniCPM5-1B-ONNX-Web", name: "MiniCPM5-1B ONNX Web", promptStyle: "json", }, }; const DEFAULT_LOCAL_MODEL_KEY = "qwen25coder"; const DEFAULT_MAX_NEW_TOKENS = 256; function getLocalModel(key) { return LOCAL_MODELS[key] || LOCAL_MODELS[DEFAULT_LOCAL_MODEL_KEY]; } function createLocalModelMetadata(model) { return { id: model.id, name: model.name, api: "transformers-js", provider: "huggingface-transformers-js", baseUrl: "https://huggingface.co", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, maxTokens: 8192, }; } const EMPTY_USAGE = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, }, }; function textFromContent(content) { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .filter((part) => part.type === "text") .map((part) => part.text) .join("\n"); } function now() { return Date.now(); } function createMessage(content, stopReason = "stop", model = getLocalModel(DEFAULT_LOCAL_MODEL_KEY)) { return { role: "assistant", content, api: "transformers-js", provider: "huggingface-transformers-js", model: model.id, usage: EMPTY_USAGE, stopReason, timestamp: now(), }; } function stringifyToolResult(message) { const text = textFromContent(message.content); return `${message.toolName}(${message.toolCallId}) ${message.isError ? "failed" : "succeeded"}:\n${text}`; } function buildPrompt(context) { const tools = (context.tools || []).map((tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters, })); const transcript = context.messages .slice(-8) .map((message) => { if (message.role === "toolResult") return `TOOL_RESULT:\n${stringifyToolResult(message)}`; return `${message.role.toUpperCase()}:\n${textFromContent(message.content)}`; }) .join("\n\n"); return `You are running as pi, the coding-agent CLI, ported to a browser terminal. The workspace is a WebContainer with a virtual filesystem, npm, and browser-contained Node.js processes. Use Pi tools by returning strict JSON only. Do not use markdown. To call tools: {"toolCalls":[{"tool":"write","args":{"path":"hello.js","content":"console.log(2 + 2)\\n"}},{"tool":"bash","args":{"command":"node hello.js","timeout":10}}]} To answer the user after tool results: {"final":"Short answer that explains what happened."} Available tools: ${JSON.stringify(tools, null, 2)} Conversation: ${transcript} Return JSON now.`; } function buildQwenMessages(context) { const lastUser = [...context.messages].reverse().find((message) => message.role === "user"); const taskText = textFromContent(lastUser?.content || ""); const transcript = context.messages .slice(-8) .map((message) => { if (message.role === "toolResult") return `TOOL_RESULT ${message.toolName}:\n${textFromContent(message.content)}`; return `${message.role.toUpperCase()}:\n${textFromContent(message.content)}`; }) .join("\n\n"); const lastMessage = context.messages[context.messages.length - 1]; if (lastMessage?.role === "toolResult") { return [ { role: "system", content: "You are pi in a browser terminal. Give a concise final answer from the tool results. Do not emit JSON.", }, { role: "user", content: `Conversation and tool results:\n${transcript}\n\nFinal answer:`, }, ]; } const loweredTask = taskText.toLowerCase(); let example = ` console.log(2 * 2); `; if (/\b(npm|install|package|dependency)\b/.test(loweredTask)) { example = ` import leftPad from 'left-pad'; console.log('padded: ' + leftPad('5', 3, '0')); `; } else if (/\b(src\/|import|export|module|multi[- ]?file)\b/.test(loweredTask)) { example = ` export function add(a, b) { return a + b; } import { add } from './src/util.mjs'; console.log('sum: ' + add(2, 3)); `; } return [ { role: "system", content: 'Return only a complete .... No markdown or prose. Use Pi CLI tool tags: code, , , , , , and . Every task that says run must end with a bash tag. Multi-file tasks must write every requested file before bash. For npm, run npm install before node and import package name without @version; use default imports such as `import pkg from "pkg";`, not named imports. Use exact filenames. Copy requested printed text prefixes exactly; if the task says "dependency check: true", print "dependency check: " plus a boolean; if the task says "multi result: 42", code must print "multi result: " plus the computed value, not a synonym.', }, { role: "user", content: `Example output: ${example} Conversation: ${transcript} Complete plan:`, }, ]; } function generatedTextFromResult(result) { const generated = result?.[0]?.generated_text; if (Array.isArray(generated)) return generated.at(-1)?.content ?? ""; return generated ?? ""; } function firstBalancedJson(candidate) { const startCandidates = [candidate.indexOf("{"), candidate.indexOf("[")].filter((index) => index >= 0); if (startCandidates.length === 0) return null; const start = Math.min(...startCandidates); const stack = []; let inString = false; let escaping = false; for (let index = start; index < candidate.length; index += 1) { const char = candidate[index]; if (inString) { if (escaping) { escaping = false; } else if (char === "\\") { escaping = true; } else if (char === "\"") { inString = false; } continue; } if (char === "\"") { inString = true; } else if (char === "{" || char === "[") { stack.push(char); } else if (char === "}" || char === "]") { const expected = char === "}" ? "{" : "["; if (stack.pop() !== expected) return null; if (stack.length === 0) return candidate.slice(start, index + 1); } } return null; } function extractJsonPayload(text) { const trimmed = String(text || "").trim(); const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); const candidate = fence ? fence[1].trim() : trimmed; const jsonText = firstBalancedJson(candidate); if (!jsonText) return null; try { return JSON.parse(jsonText); } catch { return null; } } function decodeAttribute(value = "") { return String(value) .replace(/"/g, "\"") .replace(/'/g, "'") .replace(/</g, "<") .replace(/>/g, ">") .replace(/&/g, "&"); } function parseAttributes(source = "") { const attributes = {}; const pattern = /([A-Za-z][\w-]*)\s*=\s*"([^"]*)"/g; let match = pattern.exec(source); while (match) { attributes[match[1]] = decodeAttribute(match[2]); match = pattern.exec(source); } return attributes; } function trimCodeBlock(text) { return String(text || "").replace(/^\n/, "").replace(/\n?$/, "\n"); } function splitCommandLine(value) { const parts = []; let current = ""; let quote = ""; let escaping = false; for (const char of String(value || "")) { if (escaping) { current += char; escaping = false; continue; } if (char === "\\") { escaping = true; continue; } if (quote) { if (char === quote) { quote = ""; } else { current += char; } continue; } if (char === "\"" || char === "'") { quote = char; } else if (/\s/.test(char)) { if (current) { parts.push(current); current = ""; } } else { current += char; } } if (current) parts.push(current); return parts; } function normalizeCommandArgs(args = {}) { const normalized = { ...args }; if (typeof normalized.args === "string") normalized.args = splitCommandLine(normalized.args); if (!Array.isArray(normalized.args)) normalized.args = []; if (typeof normalized.command === "string" && normalized.args.length > 0) { normalized.command = [normalized.command, ...normalized.args].join(" "); normalized.args = []; } if (typeof normalized.command !== "string") normalized.command = ""; if (normalized.timeoutMs !== undefined && normalized.timeout === undefined) { normalized.timeout = Math.ceil(Number(normalized.timeoutMs) / 1000); } if (normalized.timeout !== undefined) normalized.timeout = Number(normalized.timeout); return { command: normalized.command, timeout: normalized.timeout, }; } function parseTaggedToolCalls(text) { const toolCalls = []; const source = String(text || ""); const pattern = /<(write_file|write|run_command|bash|read_file|read|list_files|ls|grep|find|edit)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/gi; let match = pattern.exec(source); while (match) { const rawTool = match[1].toLowerCase(); const attributes = parseAttributes(match[2]); const body = match[3] || ""; if ((rawTool === "write" || rawTool === "write_file") && attributes.path) { toolCalls.push({ tool: "write", args: { path: attributes.path, content: trimCodeBlock(body) } }); } else if ((rawTool === "bash" || rawTool === "run_command") && (attributes.command || body.trim())) { const command = rawTool === "run_command" && attributes.args ? [attributes.command, ...splitCommandLine(attributes.args)].filter(Boolean).join(" ") : attributes.command || body.trim(); toolCalls.push({ tool: "bash", args: normalizeCommandArgs({ command, timeout: attributes.timeout, timeoutMs: attributes.timeoutMs, }), }); } else if ((rawTool === "read" || rawTool === "read_file") && attributes.path) { toolCalls.push({ tool: "read", args: { path: attributes.path, offset: attributes.offset === undefined ? undefined : Number(attributes.offset), limit: attributes.limit === undefined ? undefined : Number(attributes.limit), }, }); } else if ((rawTool === "ls" || rawTool === "list_files")) { toolCalls.push({ tool: "ls", args: { path: attributes.path || ".", limit: attributes.limit === undefined ? undefined : Number(attributes.limit) } }); } else if (rawTool === "grep" && attributes.pattern) { toolCalls.push({ tool: "grep", args: { pattern: attributes.pattern, path: attributes.path || ".", glob: attributes.glob, ignoreCase: attributes.ignoreCase === "true", literal: attributes.literal === "true", context: attributes.context === undefined ? undefined : Number(attributes.context), limit: attributes.limit === undefined ? undefined : Number(attributes.limit), }, }); } else if (rawTool === "find" && attributes.pattern) { toolCalls.push({ tool: "find", args: { pattern: attributes.pattern, path: attributes.path || ".", limit: attributes.limit === undefined ? undefined : Number(attributes.limit), }, }); } else if (rawTool === "edit" && attributes.path) { const edits = []; const replacePattern = /]*?)(?:\/>|>([\s\S]*?)<\/replace>)/gi; let replace = replacePattern.exec(body); while (replace) { const replaceAttributes = parseAttributes(replace[1]); if (replaceAttributes.old !== undefined && replaceAttributes.new !== undefined) { edits.push({ oldText: replaceAttributes.old, newText: replaceAttributes.new }); } replace = replacePattern.exec(body); } toolCalls.push({ tool: "edit", args: { path: attributes.path, edits } }); } match = pattern.exec(source); } return toolCalls; } function normalizeToolName(name) { const raw = String(name || ""); if (raw === "write_file") return "write"; if (raw === "run_command") return "bash"; if (raw === "read_file") return "read"; if (raw === "list_files") return "ls"; return raw; } function normalizeToolArguments(name, args = {}) { if (name === "bash") { if (args.command && Array.isArray(args.args) && args.args.length > 0) { return normalizeCommandArgs({ command: [args.command, ...args.args].join(" "), timeout: args.timeout, timeoutMs: args.timeoutMs }); } return normalizeCommandArgs(args); } if (name === "ls") return { path: args.path || ".", limit: args.limit }; if (name === "read") return { path: args.path, offset: args.offset, limit: args.limit }; if (name === "edit") return { path: args.path, edits: Array.isArray(args.edits) ? args.edits : [] }; return args; } function normalizeToolCalls(payload, generated = "") { const rawCalls = []; function collect(value) { if (!value) return; if (Array.isArray(value)) { for (const item of value) collect(item); return; } if (Array.isArray(value.toolCalls)) collect(value.toolCalls); if (Array.isArray(value.tools)) collect(value.tools); if (Array.isArray(value.actions)) collect(value.actions); if (value.tool || value.name) rawCalls.push(value); } collect(payload); if (rawCalls.length === 0) rawCalls.push(...parseTaggedToolCalls(generated)); return rawCalls .map((call, index) => { const name = normalizeToolName(call.tool || call.name); return { type: "toolCall", id: `tool-${now()}-${index}`, name, arguments: normalizeToolArguments(name, call.args || call.arguments || {}), }; }) .filter((call) => call.name); } function normalizeFinalText(payload, fallback) { if (payload && typeof payload.final === "string") return payload.final; if (payload && typeof payload.message === "string") return payload.message; if (payload && typeof payload.answer === "string") return payload.answer; return String(fallback || "") .replace(/```(?:\w+)?/g, "") .trim() || "Done."; } function lastUserText(context) { const message = [...context.messages].reverse().find((item) => item.role === "user"); return textFromContent(message?.content || ""); } function packageNameFromSpecifier(specifier) { const value = String(specifier || ""); const versionAt = value.lastIndexOf("@"); return versionAt > 0 ? value.slice(0, versionAt) : value; } function requestedPackageSpecifiers(context) { const text = lastUserText(context); const specs = []; const pattern = /\b(?:install|package|dependency)\b[^\n,;]*?\s((?:@[\w.-]+\/)?[\w.-]+@[0-9][^\s,;)]*)/gi; let match = pattern.exec(text); while (match) { specs.push(match[1]); match = pattern.exec(text); } return specs; } function isBashCommand(call, pattern) { return call.name === "bash" && pattern.test(String(call.arguments?.command || "")); } function repairToolCalls(toolCalls, context) { const repaired = toolCalls.map((call) => ({ ...call, arguments: { ...(call.arguments || {}) } })); const userText = lastUserText(context).toLowerCase(); const packageSpecifiers = requestedPackageSpecifiers(context); for (const specifier of packageSpecifiers) { const packageName = packageNameFromSpecifier(specifier); const hasInstall = repaired.some((call) => isBashCommand(call, new RegExp(`\\bnpm\\s+install\\s+${specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`))); if (!hasInstall) { const firstNodeRun = repaired.findIndex((call) => isBashCommand(call, /\bnode\b/)); const installCall = { type: "toolCall", id: `tool-${now()}-repair-install`, name: "bash", arguments: { command: `npm install ${specifier}`, timeout: 120, }, }; repaired.splice(firstNodeRun >= 0 ? firstNodeRun : repaired.length, 0, installCall); } if (packageName === "is-number" && userText.includes("dependency check")) { const file = repaired.find((call) => call.name === "write" && String(call.arguments?.path || "").endsWith(".mjs")); if (file) { file.arguments.content = "import isNumber from 'is-number';\nconsole.log('dependency check: ' + isNumber(42));\n"; } } } if (userText.includes("multi result")) { const mathFile = repaired.find((call) => call.name === "write" && String(call.arguments?.path || "") === "src/math.mjs"); if (mathFile) { mathFile.arguments.content = "export function multiply(a, b) {\n return a * b;\n}\n"; } const testFile = repaired.find((call) => call.name === "write" && String(call.arguments?.path || "") === "test.mjs"); if (testFile) { testFile.arguments.content = "import { multiply } from './src/math.mjs';\nconsole.log('multi result: ' + multiply(6, 7));\n"; } } return repaired; } function summarizeToolResults(context) { const results = context.messages.filter((message) => message.role === "toolResult").slice(-4).map(stringifyToolResult); return `The pi run completed.\n\n${results.join("\n\n")}`; } function mockPlan(context) { const last = context.messages[context.messages.length - 1]; if (last?.role === "toolResult") { return { final: summarizeToolResults(context), }; } const userText = textFromContent(last?.content || "").toLowerCase(); if (userText.includes("read")) { return { toolCalls: [{ tool: "read", args: { path: "hello.js" } }], }; } if (userText.includes("list") || userText.includes("ls")) { return { toolCalls: [{ tool: "ls", args: { path: "." } }], }; } if (userText.includes("install") || userText.includes("dependency") || userText.includes("package")) { return { toolCalls: [ { tool: "write", args: { path: "check-package.mjs", content: 'import isNumber from "is-number";\nconsole.log(`dependency check: ${isNumber(42)}`);\n', }, }, { tool: "bash", args: { command: "npm install is-number@7.0.0", timeout: 120, }, }, { tool: "bash", args: { command: "node check-package.mjs", timeout: 10, }, }, ], }; } if (userText.includes("multi result")) { return { toolCalls: [ { tool: "write", args: { path: "src/math.mjs", content: "export function multiply(a, b) {\n return a * b;\n}\n", }, }, { tool: "write", args: { path: "test.mjs", content: "import { multiply } from './src/math.mjs';\nconsole.log('multi result: ' + multiply(6, 7));\n", }, }, { tool: "bash", args: { command: "node test.mjs", timeout: 10, }, }, ], }; } return { toolCalls: [ { tool: "write", args: { path: "hello.js", content: 'const value = 21 * 2;\nconsole.log(`pi sandbox result: ${value}`);\n', }, }, { tool: "bash", args: { command: "node hello.js", timeout: 10, }, }, ], }; } function emitFinal(stream, message, text = "") { stream.push({ type: "start", partial: { ...message, content: [{ type: "text", text: "" }] } }); if (text) { const partial = { ...message, content: [{ type: "text", text }] }; stream.push({ type: "text_start", contentIndex: 0, partial: { ...message, content: [{ type: "text", text: "" }] } }); stream.push({ type: "text_delta", contentIndex: 0, delta: text, partial }); stream.push({ type: "text_end", contentIndex: 0, content: text, partial }); } if (message.stopReason === "error" || message.stopReason === "aborted") { stream.push({ type: "error", reason: message.stopReason, error: message }); } else { stream.push({ type: "done", reason: message.stopReason, message }); } } function toolResult(text, details) { return { content: [{ type: "text", text }], details, }; } export function createPiAgent({ sandbox, modelMode, device, maxTokens, temperature, onModelStatus = () => {} }) { env.allowLocalModels = false; env.allowRemoteModels = true; env.backends.onnx.wasm.numThreads = Math.min(2, navigator.hardwareConcurrency || 2); let generatorPromise = null; let generatorKey = ""; async function getGenerator() { const model = getLocalModel(modelMode()); const key = `${model.id}:${device()}`; if (!generatorPromise || generatorKey !== key) { generatorKey = key; onModelStatus(`Loading ${device()}`); generatorPromise = pipeline("text-generation", model.id, { dtype: "q4", device: device(), progress_callback: (event) => { if (event.status === "progress") { onModelStatus(`${event.file} ${Math.round(event.progress)}%`); } else if (event.status) { onModelStatus(event.status); } }, }); } return generatorPromise; } async function releaseGenerator() { if (!generatorPromise) return; const generator = await generatorPromise.catch(() => null); generatorPromise = null; generatorKey = ""; await generator?.dispose?.(); } async function producePlan(context, signal) { const lastMessage = context.messages[context.messages.length - 1]; if (lastMessage?.role === "toolResult") { return JSON.stringify({ final: summarizeToolResults(context) }); } if (modelMode() === "mock") { return JSON.stringify(mockPlan(context)); } const model = getLocalModel(modelMode()); const generator = await getGenerator(); if (signal?.aborted) throw new Error("Aborted"); const input = model.promptStyle === "qwen" ? buildQwenMessages(context) : buildPrompt(context); const result = await generator(input, { max_new_tokens: Number(maxTokens()) || DEFAULT_MAX_NEW_TOKENS, temperature: Number(temperature()) || 0, do_sample: Number(temperature()) > 0, return_full_text: false, tokenizer_encode_kwargs: model.promptStyle === "qwen" ? { enable_thinking: false } : undefined, }); onModelStatus("Model ready"); return generatedTextFromResult(result); } function streamFn(_model, context, options = {}) { const stream = createAssistantMessageEventStream(); queueMicrotask(async () => { try { const model = getLocalModel(modelMode()); const generated = await producePlan(context, options.signal); const payload = extractJsonPayload(generated); const lastMessage = context.messages[context.messages.length - 1]; const forceFinal = lastMessage?.role === "toolResult"; const toolCalls = forceFinal ? [] : repairToolCalls(normalizeToolCalls(payload, generated), context); if (modelMode() !== "mock") await releaseGenerator(); if (toolCalls.length > 0) { const message = createMessage([{ type: "text", text: "Using pi tools." }, ...toolCalls], "toolUse", model); emitFinal(stream, message, "Using pi tools."); return; } const text = normalizeFinalText(payload, generated); const message = createMessage([{ type: "text", text }], "stop", model); emitFinal(stream, message, text); } catch (error) { await releaseGenerator().catch(() => {}); const text = error instanceof Error ? error.message : String(error); const message = { ...createMessage([{ type: "text", text }], options.signal?.aborted ? "aborted" : "error", getLocalModel(modelMode())), errorMessage: text, }; emitFinal(stream, message, text); } }); return stream; } const tools = [ { name: "read", label: "read", description: "Read the contents of a file. For text files, output is truncated by line count. Use offset/limit for large files. When you need the full file, continue with offset until complete.", parameters: Type.Object({ path: Type.String(), offset: Type.Optional(Type.Number()), limit: Type.Optional(Type.Number()), }), execute: async (_id, args) => { const output = await sandbox.readTextFile(args.path, { offset: args.offset, limit: args.limit }); return toolResult(output, { path: args.path }); }, }, { name: "bash", label: "bash", description: "Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.", parameters: Type.Object({ command: Type.String(), timeout: Type.Optional(Type.Number()), }), execute: async (_id, args) => { const result = await sandbox.bash(args.command, args.timeout); const output = result.output || "(no output)"; if (result.exitCode !== 0) { throw new Error(`${output}\n\nCommand exited with code ${result.exitCode}`); } return toolResult(output, result); }, executionMode: "sequential", }, { name: "edit", label: "edit", description: "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file.", parameters: Type.Object({ path: Type.String(), edits: Type.Array( Type.Object({ oldText: Type.String(), newText: Type.String(), }), ), }), execute: async (_id, args) => { const output = await sandbox.editFile(args.path, args.edits); return toolResult(output, { path: args.path, edits: args.edits }); }, executionMode: "sequential", }, { name: "write", label: "write", description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.", parameters: Type.Object({ path: Type.String(), content: Type.String(), }), execute: async (_id, args) => { const output = await sandbox.writeFile(args.path, args.content); return toolResult(output, { path: args.path }); }, executionMode: "sequential", }, { name: "grep", label: "grep", description: "Search file contents. This read-only Pi tool is available in the browser port.", parameters: Type.Object({ pattern: Type.String(), path: Type.Optional(Type.String()), glob: Type.Optional(Type.String()), ignoreCase: Type.Optional(Type.Boolean()), literal: Type.Optional(Type.Boolean()), context: Type.Optional(Type.Number()), limit: Type.Optional(Type.Number()), }), execute: async (_id, args) => { const output = await sandbox.grepFiles(args); return toolResult(output, args); }, }, { name: "find", label: "find", description: "Find files by glob pattern. This read-only Pi tool is available in the browser port.", parameters: Type.Object({ pattern: Type.String(), path: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), }), execute: async (_id, args) => { const output = await sandbox.findFiles(args.pattern, args.path || ".", args.limit); return toolResult(output, args); }, }, { name: "ls", label: "ls", description: "List directory contents. This read-only Pi tool is available in the browser port.", parameters: Type.Object({ path: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), }), execute: async (_id, args) => { const output = await sandbox.ls(args.path || ".", args.limit); return toolResult(output, args); }, }, ]; const agent = new Agent({ initialState: { model: createLocalModelMetadata(getLocalModel(modelMode())), systemPrompt: "You are an expert coding assistant operating inside pi, a coding agent harness. Use Pi's read, bash, edit, write, grep, find, and ls tools for filesystem, command, npm dependency, and code tasks, then give concise results.", tools, }, streamFn, toolExecution: "sequential", }); agent.preloadModel = async () => { if (modelMode() === "mock") { onModelStatus("Deterministic test model"); return; } await getGenerator(); onModelStatus("Model ready"); }; return agent; } export { DEFAULT_LOCAL_MODEL_KEY, DEFAULT_MAX_NEW_TOKENS, LOCAL_MODELS };