Build an AI Agent in Typescript / lesson 5 of 8

Task Persistence

Stop losing complex project progress when your local AI session ends.

Play
Transcript

[00:00] Okay, so we’ve got our agent loop. We’ve got a to-do tool and a sub-agent tool. And in this video, we are gonna add a persistent disk-based task system. So unlike the to-do, which is for short checklist, this’ll be for long multi-step processes that we can return to in a new session. So to get us started, I’m gonna create a new file here called task.ts. And I apologize in advance, I’m gonna drop a bunch of stuff in here.

[00:34] Okay, so we’ve got file system and path. We’ve got our work directory from our config, which we set up previously, which is just our working directory. We’ve got marks and to-do status, since it’s gonna be the same completed, pending, and in progress. So we’ll just mark that as task status, and we’ll export that if we need to, just to distinguish it between the two, task versus to-do. We’ve got our type of task, which is ID, subject description, status, blocked by an owner. And the block by is important because, unlike our to-do list, this isn’t necessarily sequential. This is gonna be a dependency graph. So they’re not necessarily, the AI isn’t to grab things based on the order or the ID. It’s based on what it’s blocked by. So if you have task number three, and it’s blocked by task number two, you have to complete task number two before you can start task number three. A couple consts here, just a prefix. Our file name is gonna be task underscore, and then the ID, and it’s gonna be a JSON file. And then this is our task directory that the class we create will create in our working directory.

[01:52] And then the rest of these are really just one-liners. Get the task file path based on the ID. So it’s task file prefix, the ID, and the extension in the task directory. Read the task file. So same thing, just takes in the ID, uses our task file path to parse that out as JSON. Write a file, again, just the ID. Get the path, write out the JSON. Validate a file, so we’re making sure it starts with and ends with the prefix and extension. Last task ID is dependent on this guy down here. So it’s really just gonna scan the directory and get the very last latest ID from file name. Stupid simple, just parse the end, get the number between the prefix and the extension. Task label is just gonna output. It’s just something I can use to save some typing. So it’s just the status, the ID, the subject, and this B is gonna be if it has things that it’s blocked by. And then scan task files, again, sort of a one-liner. I broke it out, but it’s read the directory, filter on the valid task file names in case we get like a weird DS store or something like that in there. Get the IDs, and then make sure it’s not null, so it wasn’t a weird file, and then sort those on the IDs. And that’s the end of our helpers.

[03:20] So we’re gonna get started on our class here. It’s task manager, and our constructor. We’re just gonna make that directory. So make der sync, and we’re just gonna use our task der, recursive true. Okay, so what we’re gonna have here is a create method, a git, an update, and a list. So I think these are relatively self-explanatory, but the create is gonna create a task. So it’s gonna take into it the subject and the description. Both of these are gonna be strings. We’re gonna say const t is a task, which we defined up above. Our ID is gonna be that last task ID plus one. We’ll drop in the subject and the description. It’s a new task, so we’ll default to pending, or blocked by, will be an empty array. And then our owner, which we’re not really gonna use today, but we may touch on later, it’s just gonna be an empty string for now. Then we’re simply gonna write our task file, the ID and the task. And then while we’re in here, we’ll just go ahead and return that guy as a JSON string. Okay, so that’s the end of our create. We’ll move on to this git. And this is gonna be super easy. It’s gonna take in an ID, which is a number, and it’s gonna return a string, which is gonna be the contents of the task file. So we just pass ID in there. We JSON stringify it. We’re all done.

[05:14] Okay, update. This one’s a slightly more complicated function. We’re gonna have an ID, which is a number. We are gonna have a status, which is a task status. We’re gonna have add. This is really just short for add blocked by. So add a ID of a task that this is dependent on. And then a remove, which again is the opposite of that. Remove a dependency. This is no longer blocking this current task. Gonna break these out a bit. And this guy is gonna return a string. Okay, so first thing we’re gonna do is get our task. We just need to pass in the ID for that. And we’re gonna see if we did get a status, which needs to be optional here. And we’ll say t.status equals status. And then we’ll also say if the status is completed, we need to clear the dependency. So I lied, we’re gonna have one other method. And we’re gonna say this.clear dependency and pass that ID in. So let me just sketch that out really quick. Okay, so we’ll get to that. So if we have add, that means that we are adding a blocker. So t.blockedBy. What we’re gonna do is we’re gonna create a new set, spread our current blockedBy in there, and then drop in our new ones. And that way we have unique values. So that’s all we’re doing there is we’re saying, merge these two things together, the add and the existing ones. Then if we have remove, that means we’re removing a blockedBy. So .blockedBy equals, and then we can just filter that. And we say, just remove any of them, or filter these down to the ones that are not in the remove. And then finally, we’re just gonna write the task file, with the updated task. And then we’ll return that guy. Okay, there’s our update, and now we’ll do our list, which is really just formatting this out for the purposes of displaying in our terminal.

[07:40] So we’ll say, entries equals scan task files. And then we’ll return, as long as we have a length, we’ll map that, call this task ID. Otherwise, we’ll return no tasks. Okay, so let’s do this map guy here. We’ll get the task. Let’s see if we have a blockedBy length. And if we do, let’s just go here. And we’ll say blockedBy these guys, so we’re just JSON stringifying that. Otherwise, that’ll be an empty string. Then we will return using that task label thing I showed earlier, the task and the blockedBy. Okay, that is our list. And now we’re gonna do this clear dependency guy, which is relatively simple. We’re just gonna say for, say const TID of scan task file. So that’s all of our files. And we’ve got all the IDs of those guys. Then we’ll say T is read the task file. And we’ll just say if we have the ID in our blockedBy, we just want to clear that. So we can actually, we can kind of just copy this guy. I don’t know if that’s exact, but we’ll drop that in there. And we’ll say filterBy the non-equal ID. Okay, so that should clear out this ID from this task’s blockedBy. And then we’ll just write the new task file. Okay, that is, I’m sure I messed something up in here, but that’s looking pretty good. So we’ve got task manager. We can create, we can get, we can update, we can list, and we can clear the dependencies when we move to completed.

[09:54] So now we’re gonna jump over to our tools and wire all this up. So we’re gonna bring in our task manager. Looks like we’re not using this to do item. Clear that out. And we’re gonna instantiate this. So const, let’s say task equals new task manager. So that’s fired up. We’ll come down here to our list of tools. And we’re not gonna need to do like a full executable, like these run edits and run right, because it’s all handled in the task manager. Very similar to what we did with to do’s previously. So we’re gonna come down here. We’re gonna end up with four of these. So we’ll start with task create, which is gonna be a tool. It’s going to have a description. Figure that out in a second. It’s gonna have an input schema, which we’ll use our Zod schema. And it’s gonna have an execute, which will always be an async function that does something. So there’s our template for task create. I have one for update. List. And get. Okay, so this would be create a new task. Be update task. This will be list all tasks. And this will be get a task by ID. Now for each of these schemas, we’re gonna be passing in the arguments that we need. So for task create, we have the subject in the description, and then we pass that into task create. So if we come down here, this guy is a bit bigger. We’ve got our ID, our status, add block by and the, I’m sorry, add block by and remove block by. So let’s let AI do that for us. So task ID status, add, remove. And then we just pass those in. Down here is that scheme is just gonna be an empty object. And we are gonna call task list. Now in this guy, we’re gonna pass in the task ID. We’re gonna send that over to task get. We’re gonna format all this. It’s a little cleaner. And the other thing we’re gonna do is jump over to our index. We used to say use it to do tool for multi-step planning, yada, yada, yada. Now we’re gonna say prefer task, create, update, and list for multi-step work, and then use the to-dos for a short checklist.

[12:35] So we’re gonna try this out. I’m gonna bring up our terminal. I’m also gonna open this window here so that we see our task folder get created. Click on run index. And we can see our task folder has been created because we fired up the task manager. I’m gonna say create three tasks, set up project, write code, write tasks, and make them depend on each other in order. So two depends on one, three depends on two. Cool, so we have our three tasks. There’s our first one, it’s not blocked by anything. Our second one is blocked by task one, and our third one is blocked by task two. So now I’m gonna say complete task one, and then list tasks. Cool. So now we have our setup was completed. Write code pending was blocked, but now it’s unblocked. Write task pending, blah, blah, blah. So it’s looking good. Now the cool thing is we can say exit our session, start a new session. Say list tasks, and then we should get a list of our tasks. Now they are, this says complete, but this write code one still says blocked by task one. So it looks like we did not clear out. Okay, so we didn’t clear out the dependency. I screwed that up. So blocked by is equal to that guy. So what I’m gonna do is I’m just gonna start all over. I’m gonna delete the task directory, kill this. We will fire up a new session, give it the exact same command. So we can see our task created just as before. Two is blocked by one, three is blocked by two. I’m gonna leave two open, and we’re gonna say complete task one, then list tasks. And I think we should see this. Oh, there it goes. Yeah, so that worked. We will clear this out. We’ll load it up again, and say list tasks. And there we go. So two is no longer blocked by one. Three is blocked by two. We now have a disk based persistent task management system.

Stop losing complex project progress when your local AI session ends. This tutorial builds a persistent, disk-based task manager for MLX on Apple Silicon Macs, enabling long-running, multi-step workflows that survive reboots and new sessions. Perfect for developers building local AI apps who need reliable dependency tracking without cloud reliance.

The Task Manager

Let’s start by creating a new file called task.ts. This is a big file, but the functions are kept very simple so it shoud be fairly easy to follow and maintain.

task.ts
import * as fs from "node:fs";
import * as path from "node:path";
import { WORKDIR } from "./config";
import { MARKS, type TodoStatus as TaskStatus } from "./todo";
export type { TaskStatus };
export type Task = {
id: number;
subject: string;
description: string;
status: TaskStatus;
blockedBy: number[];
owner: string;
};
const TASK_FILE_PREFIX = "task_";
const TASK_FILE_EXT = ".json";
const TASK_DIR = path.join(WORKDIR, ".tasks");
/* Helpers */
/** return the full path to a task file */
const taskFilePath = (id: number): string =>
path.join(TASK_DIR, `${TASK_FILE_PREFIX}${id}${TASK_FILE_EXT}`);
/** return a task from a file */
const readTaskFile = (id: number): Task => {
try {
return JSON.parse(fs.readFileSync(taskFilePath(id), "utf8"));
} catch (error) {
throw new Error(`Failed to read task file ${taskFilePath(id)}: ${error}`);
}
};
/** write a task to a file */
const writeTaskFile = (id: number, task: Task): void =>
fs.writeFileSync(taskFilePath(id), JSON.stringify(task, null, 2));
/** validate a task file name */
const validTaskFileName = (fileName: string): boolean =>
fileName.startsWith(TASK_FILE_PREFIX) && fileName.endsWith(TASK_FILE_EXT);
/** return the last task id */
const lastTaskID = (): number => {
const entries = scanTaskFiles();
return entries.at(-1) ?? 0;
};
/** return the task id from a file name */
const idFromFileName = (fileName: string): number | null => {
const id = parseInt(
fileName.slice(TASK_FILE_PREFIX.length, -TASK_FILE_EXT.length),
10,
);
return Number.isNaN(id) ? null : id;
};
/** return a task label */
const taskLabel = (t: Task, b: string = ""): string =>
`${MARKS[t.status]} #${t.id}: ${t.subject}${b}`;
/** scan task files */
const scanTaskFiles = (
dir: string = TASK_DIR,
): number[] =>
fs
.readdirSync(dir)
.filter(validTaskFileName)
.map((f) => idFromFileName(f))
.filter((entry): entry is number => entry !== null)
.sort((a, b) => a - b);
/* End Helpers */
export class TaskManager {
/** constructor */
constructor() {
fs.mkdirSync(TASK_DIR, { recursive: true });
}
/** create a new task */
create(subject: string, description = "") {
const t: Task = {
id: lastTaskID() + 1,
subject,
description,
status: "pending",
blockedBy: [],
owner: ""
};
writeTaskFile(t.id, t);
return JSON.stringify(t, null, 2);
}
/** get a task */
get(id: number): string {
return JSON.stringify(readTaskFile(id), null, 2);
}
/** update a task */
update(
id: number,
status?: TaskStatus,
add?: number[],
remove?: number[]
): string {
const t = readTaskFile(id);
if (status) t.status = status;
if (status === 'completed') this.clearDependency(id);
if (add) t.blockedBy = [...new Set([...t.blockedBy, ...add])];
if (remove) t.blockedBy = t.blockedBy.filter(b => !remove.includes(b));
writeTaskFile(id, t);
return JSON.stringify(t, null, 2);
}
/** list all tasks */
list() {
const entries = scanTaskFiles();
return entries.length ?
entries.map(tid => {
const t = readTaskFile(tid);
const b = t.blockedBy.length
? ` (blocked by: ${JSON.stringify(t.blockedBy)})`
: '';
return taskLabel(t, b);
})
: "no tasks";
}
/** clear a dependency */
clearDependency(id: number) {
for (const tid of scanTaskFiles()) {
const t = readTaskFile(tid);
if (t.blockedBy.includes(id)) {
t.blockedBy = t.blockedBy.filter(b => b !== id);
writeTaskFile(tid, t);
}
}
}
}

Add Tasks to Tools

Now let’s add our TaskManager tool to tools.ts.

tools.ts
import { tool, zodSchema, generateText, isLoopFinished } from "ai";
import {z} from "zod":
import { spawnSync } from "node: child_process";
import { api, MODEL, WORKDIR } from './config'
import { TodoManager } from "./todo";
import { TaskManager } from "./task";
const TODO = new TodoManager();
const TASK = new TaskManager();
...
/** TOOLS */
const runBash = (command: string): string => {/* ... */};
const runRead = (filePath: string, limit?: number) => {/* ... */};
const runWrite = (filePath: string, content: string) => {/* ... */};
const runEdit = (filePath: string, oldText: string, newText: string) => {/* ... */};
export const TOOLS = {
bash: tool({/* ... */}),
read_file: tool({/* ... */}),
edit_file: tool({/* ... */}),
write_file: tool({/* ... */}),
todo: tool({/* ... */}),
task_create: tool({
description: "Create a new task.",
inputSchema: zodSchema(
z.object({ subject: z.string(), description: z.string().optional() }),
),
execute: async ({ subject, description }) =>
TASK.create(subject, description),
}),
task_update: tool({
description: "Update a task.",
inputSchema: zodSchema(
z.object({
task_id: z.number(),
status: z.enum(["pending", "in_progress", "completed"]).optional(),
addBlockedBy: z.array(z.number()).optional(),
removeBlockedBy: z.array(z.number()).optional(),
}),
),
execute: async ({ task_id, status, addBlockedBy, removeBlockedBy }) =>
TASK.update(task_id, status, addBlockedBy, removeBlockedBy),
}),
task_list: tool({
description: "List all tasks.",
inputSchema: zodSchema(z.object({})),
execute: async () => TASK.list(),
}),
task_get: tool({
description: "Get a task by id.",
inputSchema: zodSchema(z.object({ task_id: z.number() })),
execute: async ({ task_id }) => TASK.get(task_id),
}),
};

Update Agent Loop

Now let’s update index.ts to use the task tool.

index.ts
const agentLoop = async (messages: ModelMessage[]): Promise<string> => {
let stepsSinceTodo = 0;
const { text } = await generateText({
model: api.chat(MODEL),
system: `You are a coding agent at $(WORKDIR}.
Use the todo tool to plan multi-step tasks. Mark in_progress before starting, completed when done.
Prefer task_create/task_update/task_list for multi-step work. Use todo for short checklists.
Use the task tool to delegate exploration or subtasks.`
messages,
tools: PARENT_TOOLS,
stopWhen: isLoopFinished(),
onStepFinish: ({ toolCalls }) => {
const usedTodo = toolCalls.some((tc) => tc.toolName === "todo") ?? false;
stepsSinceTodo = usedTodo ? 0 : stepsSinceTodo + 1;
if (stepsSinceTodo >= 3) {
messages.push({
role: "user",
content: "<reminder>Updated your todos.</reminder>",
});
}
},
});
return text;
};

Try it out

Terminal window
bun run index.ts
input » Create 3 tasks: "Setup project", "Write code", "Write tests". Make them depend on each other in order.
input » Complete task 1 and then list tasks
input » lists tasks

Share this post on:

Previous
Task Memory
Next
Context Compaction