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"; 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 a pi Agent inside a browser-only app. The sandbox is a WebContainer: it has a virtual filesystem and can spawn browser-contained Node.js processes. Use tools by returning strict JSON only. Do not use markdown. To call tools: {"toolCalls":[{"tool":"write_file","args":{"path":"hello.js","content":"console.log(2 + 2)\\n"}},{"tool":"run_command","args":{"command":"node","args":["hello.js"]}}]} 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 lastUserText = [...context.messages] .reverse() .find((message) => message.role === "user"); const taskText = textFromContent(lastUserText?.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 Web Agent. Give a concise final answer from the sandbox 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. Inside the plan use code, , , and . Every task that says run must end with a run_command tag. Multi-file tasks must write every requested file before run_command. 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.command.includes(" ") && normalized.args.length === 0) { const [command, ...rest] = splitCommandLine(normalized.command); normalized.command = command; normalized.args = rest; } if (normalized.timeoutMs !== undefined) normalized.timeoutMs = Number(normalized.timeoutMs); return normalized; } 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 isRunCommand(call, command) { if (call.name !== "run_command") return false; const args = normalizeCommandArgs(call.arguments || {}); return args.command === 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) => { if (!isRunCommand(call, "npm")) return false; const args = normalizeCommandArgs(call.arguments || {}).args; return args.includes("install") && args.includes(specifier); }); if (!hasInstall) { const firstNodeRun = repaired.findIndex((call) => isRunCommand(call, "node")); const installCall = { type: "toolCall", id: `tool-${now()}-repair-install`, name: "run_command", arguments: { command: "npm", args: ["install", specifier], timeoutMs: 120000, }, }; 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_file" && String(call.arguments?.path || "").endsWith(".mjs")); if (file) { file.arguments.content = "import isNumber from 'is-number';\nconsole.log('dependency check: ' + isNumber(42));\n"; } } } return repaired; } function parseTaggedToolCalls(text) { const toolCalls = []; const source = String(text || ""); const writePattern = /]*)>([\s\S]*?)<\/write_file>/gi; let write = writePattern.exec(source); while (write) { const attributes = parseAttributes(write[1]); if (attributes.path) { toolCalls.push({ tool: "write_file", args: { path: attributes.path, content: trimCodeBlock(write[2]), }, }); } write = writePattern.exec(source); } const emptyTagPattern = /<(run_command|read_file|list_files)\b([^>]*)>(?:\s*<\/\1>)?/gi; let tag = emptyTagPattern.exec(source); while (tag) { const tool = tag[1]; const attributes = parseAttributes(tag[2]); if (tool === "run_command" && attributes.command) { toolCalls.push({ tool, args: normalizeCommandArgs({ command: attributes.command, args: attributes.args || [], timeoutMs: attributes.timeoutMs, }), }); } else if ((tool === "read_file" || tool === "list_files") && attributes.path) { toolCalls.push({ tool, args: { path: attributes.path } }); } tag = emptyTagPattern.exec(source); } return toolCalls; } 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) => ({ type: "toolCall", id: `tool-${now()}-${index}`, name: String(call.tool || call.name || ""), arguments: call.tool === "run_command" || call.name === "run_command" ? normalizeCommandArgs(call.args || call.arguments || {}) : 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 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_file", args: { path: "hello.js" } }], }; } if (userText.includes("list")) { return { toolCalls: [{ tool: "list_files", args: { path: "." } }], }; } if (userText.includes("install") || userText.includes("dependency") || userText.includes("package")) { return { toolCalls: [ { tool: "write_file", args: { path: "check-package.mjs", content: 'import isNumber from "is-number";\nconsole.log(`dependency check: ${isNumber(42)}`);\n', }, }, { tool: "run_command", args: { command: "npm", args: ["install", "is-number@7.0.0"], timeoutMs: 120000, }, }, { tool: "run_command", args: { command: "node", args: ["check-package.mjs"], }, }, ], }; } return { toolCalls: [ { tool: "write_file", args: { path: "hello.js", content: 'const value = 21 * 2;\nconsole.log(`pi sandbox result: ${value}`);\n', }, }, { tool: "run_command", args: { command: "node", args: ["hello.js"], }, }, ], }; } function summarizeToolResults(context) { const results = context.messages.filter((message) => message.role === "toolResult").slice(-4).map(stringifyToolResult); return `The sandbox work completed.\n\n${results.join("\n\n")}`; } 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 }); } } 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()) || 128, 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 sandbox tools." }, ...toolCalls], "toolUse", model); emitFinal(stream, message, "Using sandbox 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: "list_files", label: "List files", description: "List files in the sandbox workspace.", parameters: Type.Object({ path: Type.Optional(Type.String()), }), execute: async (_id, args) => { const output = await sandbox.listFiles(args.path || "."); return { content: [{ type: "text", text: output }], details: { output } }; }, }, { name: "read_file", label: "Read file", description: "Read a UTF-8 file from the sandbox workspace.", parameters: Type.Object({ path: Type.String(), }), execute: async (_id, args) => { const output = await sandbox.readFile(args.path); return { content: [{ type: "text", text: output }], details: { path: args.path, output } }; }, }, { name: "write_file", label: "Write file", description: "Create or replace a UTF-8 file inside the sandbox workspace.", parameters: Type.Object({ path: Type.String(), content: Type.String(), }), execute: async (_id, args) => { const output = await sandbox.writeFile(args.path, args.content); return { content: [{ type: "text", text: output }], details: { path: args.path } }; }, }, { name: "run_command", label: "Run command", description: "Spawn a process inside the browser-only WebContainer sandbox.", parameters: Type.Object({ command: Type.String(), args: Type.Optional(Type.Array(Type.String())), timeoutMs: Type.Optional(Type.Number()), }), execute: async (_id, args) => { const result = await sandbox.runCommand(args.command, args.args || [], args.timeoutMs || 10000); const text = `$ ${result.command}\nexit ${result.exitCode}\n${result.output}`; return { content: [{ type: "text", text }], details: result, }; }, executionMode: "sequential", }, ]; const agent = new Agent({ initialState: { model: createLocalModelMetadata(getLocalModel(modelMode())), systemPrompt: "You are Pi Web Agent. Use the sandbox tools for filesystem, npm dependency, or command 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, LOCAL_MODELS };