Showcase / Built with Affordance
Running the pi coding agent on Affordance
pi is an open-source coding agent that you use in a terminal. You type a request, and it reads your files, runs commands and edits code until the job is done. We replaced the part of pi that talks to the model with an Affordance app, and we use that version every day.
What a coding agent does on every step
A coding agent works in a loop. It sends the model the conversation so far, along with a list of things the model may do, such as read a file or run a command. Those are called tools. The model picks one, the agent runs it, adds the result to the conversation, and asks again.
Three decisions are built into that loop: what goes into the prompt, which tools the model gets, and what to do when the conversation grows too long for the model to read. In pi these are TypeScript code inside the program. To change one you have to learn pi's internals and rebuild it.
What we moved into an Affordance app
In our fork those three decisions are an Affordance app: four files of TSX, 312 lines together. pi keeps everything you see and touch, which is its terminal interface, its saved sessions, its list of models and its extensions.
A tool in the app is a function. The model is told the function's name, the comment above it and the types of its arguments. This is pi's bash tool:
/** Execute a bash command in the working directory. Returns stdout and stderr together. Optionally give a timeout in seconds. */
async function bash({command, timeout}: {command: string; timeout?: number}) {
const limit = timeout && timeout > 0 ? timeout : 120;
const got = await shell(command, limit);
const output = String(got.output);
if (got.timed_out) throw new Error((output ? output + "\n\n" : "") + `Command timed out after ${limit} seconds`);
if (got.exit) throw new Error((output ? output + "\n\n" : "") + `Command exited with code ${got.exit}`);
return output || "(no output)";
}An example: changing when pi summarizes
When a conversation gets close to the limit of what the model can read, pi asks the model to summarize the older part and carries on with the summary in its place. How full is "close", and how many recent messages are kept as they are, are two numbers at the top of one file.
// How much of the window a prompt may fill before the next turn folds, and how many recent
// records are kept as they are.
const FULL = 0.8;
const KEEP = 12;To summarize earlier, at 60% full, change FULL to 0.6 and start pi again. The rest of the file is the rule itself, in 24 lines. It waits for a prompt that is too full, picks where to cut, asks the model for the summary, and puts the summary where the older messages were.
useEvent("layout", ({ prompt_tokens, window }) => {
if (window && prompt_tokens > FULL * window) setDue(true);
});
...let cut = records.length - KEEP;
while (cut > 0 && records[cut].kind !== "input" && records[cut].kind !== "model") cut--;
if (cut <= 1) return;
const out = await model(<Summarize records={records.slice(0, cut)}/>, {max_new: 2000, tag: "fold"});
if (out.aborted || !out.text) return;
const summary = {kind: "input", content: "[Summary of the conversation so far]\n" + out.text};
log.reset([summary, ...records.slice(cut).map(({ id, ...record }) => record)]);Our fork is based on pi v1.5.1. The comparison with Pi Durable looks at pi's own package for agents that survive a restart.