Transcript
[00:00] In a previous video, we created our local agent loop using Ollama as our open API like API. We’ve only got one tool in here called Runbash and everything here works great. Now what we’re gonna do is create some more specific tools. Runbash is awesome, but we may run into limitations. So we wanna create a way to specifically read, edit, and write a file.
[00:33] The first thing I’m gonna do is create a new file called tools TS. And I’m gonna bring in all of our tool stuff. I’ll bring in some extra things and then we’ll clean that up. Donate this. We’re gonna need our working directory and our block commands. Donate the API. There is our tools, which we’re gonna export. And then let’s see, we need this. Donate that. We don’t need that right now. And we don’t need this yet. We might need it though. Okay, so that is our bash tool kind of recreated in an external file.
[01:12] So say import tools from that file, and then we can get rid of all of this for the time being. And we don’t need that. We don’t need that. And at the moment, we don’t need either of these. We don’t need that guy. Okay. So all that’s cleaned up. I’m just gonna double check that it works. All right. So we got four. Our agent still works. And jump over to tools. And like I said, what we want to create is a specific tool for reading, writing, and editing files.
[01:56] So we’re gonna start with our, we’ll call it run read. It’s gonna take a file path, which is a string, and then an optional limit, which is a number. So limiting the number of lines that the agent wants to read. So, all of these are gonna be wrapped in a try catch, and the error will just return there. So now in the try, we’re gonna say, we’re gonna have this fp, because we’re gonna write another function that makes sure we’re inside of our working directory. But for right now, I’m just gonna say that’s equal to file path, and then we’ll get our lines.
[02:44] And that’s gonna be, we’re gonna need to bring in a node file system and node path. So here, this will be fs, our file system, dot read, file sync, and that’ll just be our file path, basic encoding, and then we’re gonna split that guy on the line breaks. And then we’ll just return, depending on whether or not we have a limit. So if we have a limit, say lines.slice, zero up to the limit. Otherwise, it’ll just be the lines. And then we’re gonna join that back together on the line break. And then we’re gonna internally just say that we’re not gonna go over 50,000 lines. Okay, so there’s our run read.
[03:28] Now we’re gonna come down here to our tools. And I think for the most part, this is gonna be very similar. We can use that as a template. So this would be read file. Our description would be read a file. Our schema is obviously gonna be a little bit different. So we’re gonna have our file path. And then we’re gonna have optional limit. And then our execute. I’m just gonna rewrite that guy. So execute. Take in our file path and our limit. And then just call run read with the file path and the limit. So there’s our read file tool.
[04:20] Now we’re gonna follow suit with our run write and run edit. So let’s do our run write. It’d be very similar except we’ll have a file path and content. So that’s what the content that we wanna write to the new file. First, we’re gonna make sure we have a directory to write to based on the file path. We’ll say path dot der name of file path. And we’ll just say recursive true to make sure that we get down to any nested directories. And then we’ll write our file. So write file sync. And that’s gonna be our file path and then our content. And then we’ll just return that we wrote the file. We’ll add the file path in there. And while we’re here, we’ll go ahead and do our edit.
[05:13] So run edit. This would be a little bit different. So we’re gonna have old text and new text. So we’ve got a file path. We know what the old text we’re looking for. And we’re gonna replace it with the new text. And say our content is gonna be read the file. And we’re gonna say if the content doesn’t include the old text. And we’ll just agree with the AI here. We’re just gonna say that we didn’t find it. And then we’re just gonna say let’s write the file. So we’ve got FS write file sync. We’ve got our file path. And then we’re gonna say content.replace old text with the new text. And we’ll say we edited the file. Okay. I think this is looking pretty good.
[06:10] Now we’re gonna add these to our tools. So we’ll just copy this read file. Then we’ll make write file. Say write a file. Got our file path. Got our old text. And our new text. So this will be file path. Old text. And new text. And then we’ll just pass those into. Run write. Now you know what? I was actually doing our edit tool. So I’m just gonna switch over to that. So this is our edit file. Edit a file. And run edit. Which is gonna make it really easy to do. What I meant to do. Which is write file. So write file. It’s gonna be there. Say write a file. And then this will just be content. And we don’t need this guy. This will just be content. File path. And content. And run write. Okay. So we got to read, edit, and write. We’ve still got our bash. It’s a perfect fallback. And all of this is looking pretty good.
[07:32] I’m gonna jump over here. And we’re gonna say instead of use bash, we’re gonna say use tools to solve tasks. And that’s it. That just says go in. Look at your tools. Now there is one other thing I was gonna do here. Which is I mentioned it on these FP equals file path. We want to make sure that we’re in the working directory. So we’re gonna create a quick function here called safe path. And it’s just gonna take in the path, which is a string. And we’re just trying to make sure that we’re in our working directory. So we’ll say const resolved. We’ll use resolve to make sure that it actually exists. So we’ll say workdir and the path that it’s looking for. So that should be path dot resolve. And then we’ll say if…
[08:17] So if the resolved path doesn’t include the work directory or it doesn’t start with it, then we know we have a problem. So we’ll say resolved dot starts with. And here we’ll just return. Which, you know, we’ll do actually throw there. So we’ll say throw new error. Path is not in. And if we’re all good and that hasn’t happened, all we gotta do is return resolved. And then on each of these guys, we’re gonna say the file path is safe path around that guy. So I’ll just copy that into each one of these. Okay. So now our guy is gonna use any of the available tools. We’ve got read a file, edit a file, write a file. And then we’ve got run a shell command. So if we come back in.
[09:15] Oop. Got a issue on that safe path one. There we go. Try that one more time. There’s our input. And we’re gonna try our create. So this is gonna be our write of file. So create a file called greet TS with a greet name and string function. Cool. Says it created it. We’ll look at greet. And sure enough, there it is. And I will try our edit. So I’m gonna say edit it and add some comments at the top of the function. And we can see it did in fact add those. And now we’re just gonna do a read. So I’m saying read it and verify the edit worked, which means it’s gonna have to use our history to keep the context of the conversation. And that’s something that we built into this last time.
[10:16] Awesome. So there you go. We have isolated our tools away from our primary agent loop. And now we just import those. Clean this up really quick. We’ve added three new tools. On top of the existing bash tool, we’ve got a read, write and edit tool available to our agent loop.
Let’s add structured file tools — read_file, write_file, and edit_file — so the agent can understand and modify your codebase safely and predictably.
Instead of blindly executing shell commands, we give it precise capabilities. The result is more reliable edits, better reasoning, and cleaner iterations.
And the first thing we’ll do is create a new file called tools.ts where we’ll copy over our runBash function and TOOLS object from index.ts.
import { tool, zodSchema } from "ai";import {z} from "zod":import { spawnSync } from "node: child_process";
/** CONSTANTS */const WORKDIR = process.cwd();const BLOCKED_COMMANDS = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"];
/** TOOLS */const runBash = (command: string): string => { if (BLOCKED_COMMANDS.some((c) => command.includes(c))) { return "Error: Danger Will Robinson!!!"; } try { const result = spawnSync("sh", ["-c", command], { cwd: WORKDIR, encoding: "utf8", timeout: 120000, }); return (result.stdout + result.stderr).trim().slice(0, 50000) || ""; } catch (e) { return `Error ${e}`; }};
export const TOOLS = { // <-- NOTE: we need to export bash: tool({ description: "Run a shell command", inputSchema: zodSchema(z.object({ command: z.string() })), execute: async ({ command }: { command: string; }) => { const output = runBash(command); return output; }, })};Next we’ll update our index.ts to utilize the new tools.ts.
#!/usr/bin/env bun
import { createOpenAI } from "@ai-sdk/openai";import { generateText, isLoopFinished, tool, zodSchema } from "ai";import { generateText, isLoopFinished } from "ai";import type { ModelMessage } from "ai";import { z } from "zod";import { spawnSync } from "child_process";import * as readline from "readline";import { TOOLS } from './tools'
/** CONSTANTS */const WORKDIR = process.cwd();const MODEL = "qwen3.5:35b-a3b-coding-nvfp4";const BLOCKED_COMMANDS = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"];
/** API */const ollama = createOpenAI({ baseURL: "http://localhost:11434/v1", apiKey: "ollama",});
/** TOOLS */const runBash = (command: string): string => { if (BLOCKED_COMMANDS.some((c) => command.includes(c))) { return "Error: Danger Will Robinson!!!"; } try { const result = spawnSync("sh", ["-c", command], { cwd: WORKDIR, encoding: "utf8", timeout: 120000 }); return (result.stdout + result.stderr).trim().slice(0, 50000) || "";
} catch (e) { return `Error ${e}`; }};
const TOOLS = { bash: tool({ description: "Run a shell command", inputSchema: zodSchema(z.object({ command: z.string() })), execute: async ({ command }: { command: string; }) => { const output = runBash(command); return output; } })};
...
prompt();Let’s double check that the agent loop still functions.
bun run index.tsinput >> what is 2+2?4Excellent, it’s still working. Now lets create our specific file tools for reading, writing, and editing files.
import { tool, zodSchema } from "ai";import {z} from "zod":import { spawnSync } from "node: child_process";
/** CONSTANTS */const WORKDIR = process.cwd();const BLOCKED_COMMANDS = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"];
const safePath = (p: string) => { const resolved = path.resolve(WORKDIR, p); if (!resolved.startsWith(WORKDIR)) { throw new Error(`Path is not in ${WORKDIR}`); } return resolved;};
/** TOOLS */const runBash = (command: string): string => { if (BLOCKED_COMMANDS.some((c) => command.includes(c))) { return "Error: Danger Will Robinson!!!"; } try { const result = spawnSync("sh", ["-c", command], { cwd: WORKDIR, encoding: "utf8", timeout: 120000, }); return (result.stdout + result.stderr).trim().slice(0, 50000) || ""; } catch (e) { return `Error ${e}`; }};
const runRead = (filePath: string, limit?: number) => { try { const fp = safePath(filePath); const lines = fs.readFileSync(fp, "utf8").split("\n"); return (limit ? lines.slice(0, limit) : lines).join("\n").slice(0, 50000); } catch (e) { return `Error ${e}`; }};
const runWrite = (filePath: string, content: string) => { try { const fp = safePath(filePath); fs.mkdirSync(path.dirname(fp), { recursive: true }); fs.writeFileSync(fp, content); return `Wrote the file: ${fp}`; } catch (e) { return `Error ${e}`; }};
const runEdit = (filePath: string, oldText: string, newText: string) => { try { const fp = safePath(filePath); const content = fs.readFileSync(fp, "utf8"); if (!content.includes(oldText)) { return `Error: ${oldText} not found in ${fp}`; } fs.writeFileSync(fp, content.replaceAll(oldText, newText)); return `Edited the file: ${fp}`; } catch (e) { return `Error ${e}`; }};
export const TOOLS = { // <-- NOTE: we need to export bash: tool({ description: "Run a shell command", inputSchema: zodSchema(z.object({ command: z.string() })), execute: async ({ command }: { command: string; }) => { const output = runBash(command); return output; }, }), read_file: tool({ description: "Read a file", inputSchema: zodSchema( z.object({ filePath: z.string(), limit: z.number().optional() }), ), execute: async ({ filePath, limit }) => runRead(filePath, limit), }), edit_file: tool({ description: "Edit a file", inputSchema: zodSchema( z.object({ filePath: z.string(), oldText: z.string(), newText: z.string(), }), ), execute: async ({ filePath, oldText, newText }) => runEdit(filePath, oldText, newText), }), write_file: tool({ description: "Write a file", inputSchema: zodSchema( z.object({ filePath: z.string(), content: z.string() }), ), execute: async ({ filePath, content }) => runWrite(filePath, content), }),};Now let’s make a small change to our agent loop in index.ts. We’ll update the system prompt tu use tools instead of use bash.
const agentLoop = async (messages: ModelMessage[]): Promise<string> => { const { text } = await generateText({ model: ollama.chat(MODEL), system: `You are a coding agent at ${WORKDIR}. Use bash to solve tasks. Act, dont explain.`, system: `You are a coding agent at ${WORKDIR}. Use bash to tools tasks. Act, dont explain.`, messages, tools: TOOLS, stopWhen: isLoopFinished(), }); return text;};});Now go ahead and try the agent.
→bun run index.tsinput » Create a file called greet.ts with a greet(name: string) functioninput » Edit greet.ts to add a JSdoc comment to the functioninput » Read greet.ts to verify the edit worked