Spaces:
Sleeping
Sleeping
| 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 = `<plan> | |
| <write path="app.js"> | |
| console.log(2 * 2); | |
| </write> | |
| <bash command="node app.js" timeout="10"></bash> | |
| </plan>`; | |
| if (/\b(npm|install|package|dependency)\b/.test(loweredTask)) { | |
| example = `<plan> | |
| <write path="pad.mjs"> | |
| import leftPad from 'left-pad'; | |
| console.log('padded: ' + leftPad('5', 3, '0')); | |
| </write> | |
| <bash command="npm install left-pad@1.3.0" timeout="120"></bash> | |
| <bash command="node pad.mjs" timeout="10"></bash> | |
| </plan>`; | |
| } else if (/\b(src\/|import|export|module|multi[- ]?file)\b/.test(loweredTask)) { | |
| example = `<plan> | |
| <write path="src/util.mjs"> | |
| export function add(a, b) { | |
| return a + b; | |
| } | |
| </write> | |
| <write path="test.mjs"> | |
| import { add } from './src/util.mjs'; | |
| console.log('sum: ' + add(2, 3)); | |
| </write> | |
| <bash command="node test.mjs" timeout="10"></bash> | |
| </plan>`; | |
| } | |
| return [ | |
| { | |
| role: "system", | |
| content: | |
| 'Return only a complete <plan>...</plan>. No markdown or prose. Use Pi CLI tool tags: <write path="...">code</write>, <bash command="..." timeout="10"></bash>, <read path="..." offset="1" limit="200"></read>, <ls path="."></ls>, <grep pattern="..." path="." glob="*.js"></grep>, <find pattern="*.js" path="."></find>, and <edit path="..."><replace old="..." new="..."></replace></edit>. 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 = /<replace\b([^>]*?)(?:\/>|>([\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 }; | |