diff --git a/agent.ts b/agent.ts index 739a1a6..e3f4974 100644 --- a/agent.ts +++ b/agent.ts @@ -12,10 +12,23 @@ import { sendMessage, type Response } from "./client" import { findTool } from "./tools" import type { ChatCompletionMessageParam } from "openai/resources/chat/completions" +class ToolApprovalRejectedError extends Error { + constructor(toolName: string) { + super(`Approval rejected for tool: ${toolName}`) + this.name = "ToolApprovalRejectedError" + } +} + const bold = (s: string) => `\x1b[1m${s}\x1b[0m` const dim = (s: string) => `\x1b[2m${s}\x1b[0m` const cyan = (s: string) => `\x1b[36m${s}\x1b[0m` +const approvalRequiredTools = new Set([ + "write_file", + "edit_file", + "bash", +]) + const conversation: ChatCompletionMessageParam[] = [] console.log(cyan(` @@ -28,11 +41,37 @@ while (true) { const input = prompt(bold("you> ")) if (!input) continue + if (input.trim().startsWith("run ")) { + const command = input.trim().slice(4) + + console.log(dim(`\n Agent wants to use tool: ${cyan("bash")}`)) + console.log(dim(` Input: ${JSON.stringify({ command }, null, 2)}`)) + + const approved = prompt(bold("approve? (y/n) ")) + + if (approved?.toLowerCase() !== "y") { + console.log(dim("\n Approval rejected. No changes were made.")) + continue + } + + const tool = findTool("bash") + const result = tool + ? await tool.call({ command }) + : "Error: bash tool not found" + + console.log(dim(` [${cyan("bash")}] ${JSON.stringify({ command })}`)) + console.log(String(result) || dim(" ")) + continue +} + conversation.push({ role: "user", content: input }) try { process.stdout.write("\n" + bold("agent> ")) let response: Response = await sendMessage(conversation) + if (!response.wantsToUseTools && response.content) { + process.stdout.write(response.content) + } // The inference loop — keep going while the model wants to use tools while (response.wantsToUseTools) { @@ -41,13 +80,31 @@ while (true) { // Execute all requested tools in parallel const toolResults = await Promise.all( response.toolCalls.map(async (tc) => { - const tool = findTool(tc.function.name) + const toolName = tc.function.name + const tool = findTool(toolName) const input = JSON.parse(tc.function.arguments) + + if (approvalRequiredTools.has(toolName)) { + console.log(dim(`\n Agent wants to use tool: ${cyan(toolName)}`)) + console.log(dim(` Input: ${JSON.stringify(input, null, 2)}`)) + + const approved = prompt(bold("approve? (y/n) ")) + + if (approved?.toLowerCase() !== "y") { + throw new ToolApprovalRejectedError(toolName) + } + } + const result = tool ? await tool.call(input) - : `Error: unknown tool '${tc.function.name}'` + : `Error: unknown tool '${toolName}'` + + console.log(dim(` [${cyan(toolName)}] ${JSON.stringify(input)}`)) - console.log(dim(` [${cyan(tc.function.name)}] ${JSON.stringify(input)}`)) + if (toolName === "bash") { + console.log(dim("\n Command output:")) + console.log(String(result) || dim(" ")) + } return { role: "tool" as const, @@ -64,6 +121,9 @@ while (true) { // Ask the model again — it now has the tool results process.stdout.write("\n" + bold("agent> ")) response = await sendMessage(conversation) + if (!response.wantsToUseTools && response.content) { + process.stdout.write(response.content) + } } // Text was already streamed, just record it @@ -73,6 +133,11 @@ while (true) { // Remove the user message we just pushed — the turn failed conversation.pop() + if (e instanceof ToolApprovalRejectedError) { + console.log(dim("\n Approval rejected. No changes were made.")) + continue + } + const code = e?.error?.code || e?.code if (code === "ConnectionRefused" || code === "ECONNREFUSED") { console.log(dim("\n Connection refused — is LM Studio running on localhost:1234?")) diff --git a/client.ts b/client.ts index 9b28e36..5a3a38c 100644 --- a/client.ts +++ b/client.ts @@ -5,11 +5,39 @@ import type { ChatCompletionMessageParam } from "openai/resources/chat/completio const MODEL = "qwen2.5-coder-14b-instruct" const SYSTEM_PROMPT = `You are a helpful coding agent. You have access to tools that let you -read files, list directories, and edit code. +read files, list directories, edit code, and run shell commands. + +IMPORTANT RULES: + +- If the user asks to create, edit, delete, move, or inspect files: + ALWAYS use tools. + NEVER only describe how to do it manually. + +- If the user asks to run terminal commands: + ALWAYS use the bash tool. + NEVER simulate command output. + +- If a request requires a tool: + USE THE TOOL. + DO NOT explain how to do it manually instead. + +- Do not pretend commands were executed if they were not. + +- Do not provide hypothetical shell commands instead of using tools. + +- If a tool execution is rejected by the user: + stop the current task immediately. + do not attempt workarounds. + do not retry automatically. + do not explain alternative manual steps unless explicitly asked. -Use your tools to look at actual files rather than guessing about their contents. When you're done, respond with a clear summary of what you did or found. +RULE: +If a request requires a tool, +DO NOT explain how to do it manually. +USE THE TOOL. + ## Environment - User: ${Bun.spawnSync(["whoami"]).stdout.toString().trim()} - OS: ${Bun.spawnSync(["uname", "-s"]).stdout.toString().trim()} ${Bun.spawnSync(["uname", "-r"]).stdout.toString().trim()} @@ -48,6 +76,7 @@ export async function sendMessage( model: MODEL, messages, tools: tools.map((t) => t.definition), + tool_choice: "auto", max_tokens: 4096, stream: true, }) @@ -64,7 +93,6 @@ export async function sendMessage( if (choice.delta.content) { content += choice.delta.content - process.stdout.write(choice.delta.content) } if (choice.delta.tool_calls) {