Giving an agent a workspace instead of a list of tools
I gave an agent a workspace of stateful objects instead of a list of tools, then stopped one method from returning everything at once.
A tool is a function call. The model asks for something, a result comes back as text, and the model reads that text and decides what to do next. This works, and it has a property nobody mentions: the tools have no memory. Each call is complete in itself, so anything the model wants to keep it keeps in the conversation and re-reads on the next turn.
Motivation
For small jobs that lack of memory is invisible. For anything that runs a while it becomes the whole problem, because the model is carrying its own working memory inside the conversation and re-reading all of it every turn.
A second problem sits underneath it: once an agent is inside its loop it is very hard to use from a program. A script that wants an agent to work something out mid-way through has to define it, let it run to a step limit, and then get an answer back so the script can carry on. Three things go wrong.
- Getting the answer back is awkward. Everything the agent did comes out as a nested pile of messages you have to process to find out what happened.
- Constraining it is awkward. The only way to make it do anything is to offer a tool and hope it calls one.
- There is no clean way to hand control back, because the only exit is a tool call, and a tool call continues the loop rather than returning from it.
A function that finishes can do one of two things: call something else, or return, meaning hand a value back to its caller and disappear. An agent loop only has the first. Every tool call feeds its result back into the loop and runs another turn, and there is no gesture meaning "stop, here is the answer, give it to the program that started me".
The usual approximation is a tool called finish watched for from outside, which works the way a
goto works: fine until you want two of them nested.
Lisp calls the general machinery for this a continuation. An agent framework needs only the ordinary half of it: a way to end the loop with a value rather than with another message.
The translation
"The model should not have to carry its own memory" is a want, not a specification. Carrying something means holding a value and reading it back, so the question is where the value lives. Two places are available:
- In the transcript, which is what tools give you. The value is text, re-read every turn, costing its own length on every subsequent call.
- Beside the compute, addressed by a name. The value is a thing, the model holds a handle to it, and reading it is a call rather than a cost already paid.
The second is a runtime with persistent objects in it, which makes the requirements concrete:
- objects survive between calls
- objects are addressable by name, so a handle passes around instead of a value
- whatever the model needs in order to use one travels with the object, because nothing human is going to read the documentation
The object model
The type has to answer a question an ordinary class never has to: what must an object carry so that something which has never seen it before can use it correctly on the first attempt? A class in a normal program does not carry that and does not need to. The names do most of the work, and the documentation is a courtesy for whoever maintains it later. Nothing human reads this one.
export interface ObjectClass<S = State> {
name: string;
description: string;
defaultState: S;
methods: Record<string, MethodDef<S>>;
}
export interface MethodDef<S = State> {
description: string; // what this method does and when to use it
example?: string; // example usage
fn: (state: S, ...args: any[]) => any;
}Only description is required; the example is optional, and the methods that needed one were the
methods whose call shape was not obvious from the name.
The description and the example are the interface, not documentation beside it. A worklog's log
method carries the example invoke("daily-log", "log", ["Implemented feature X", ["dev", "feature"]]), and an agent that has never seen a worklog can read that and construct a valid call.
Objects live as JSON in SQLite and are hydrated with their class only when a method is invoked, which means the object is virtual: it exists during the call and is a row the rest of the time.
Method constraints
The requirement shows up with the first object big enough to matter. A worklog with two years of
entries in it is too big to read, and the obvious interface hands the agent a getAll and lets it
discover that the hard way.
The instinct is to fix that with instruction. Tell the agent to check the size before reading, or to process long logs in chunks. That is an instruction it can forget by the third tool call, and an agent that has forgotten looks exactly like one that was never told.
Instead the worklog has peek, and the whole idea is visible in its definition:
peek: {
description: "Get metadata about the worklog without loading entries. " +
"Use this FIRST to understand scale before deciding how to process.",
example: 'invoke("daily-log", "peek")',
fn: (state) => ({
count: state.entries.length,
earliest: /* oldest timestamp */,
latest: /* newest timestamp */,
tags: /* every tag in use, sorted */,
}),
}A count, a time range, and the tag vocabulary. No entry ever comes back. The instruction to call it
first sits in the description because the description is what the agent reads, and it holds because
the shape of the return value makes any other order pointless: there is no way to get an entry out
of peek, so an agent that wants entries has to go somewhere else and pick a slice.
Underneath it sit the readers that take slices rather than everything: range between two timestamps,
lastN, lastHours, today. The range method's own description says it is for chunked processing of
large logs.
Marked in the source as constraint methods, none of them does anything useful on its own; what each one does is make a shortcut unavailable.
The journal indexing test
The test was two years of journal data that needed indexing and summarising. Rather than writing the processing logic, I created the object types, populated the environment with the data, and asked the agent to work out a strategy.
It chose to chunk by time period, summarise each chunk, and then synthesise the summaries into a whole. Which is map-reduce, and nobody wrote the loop.
The narrow reading is the one worth keeping. The affordances already pointed at chunking, since one method says to call it first to understand scale and another says it exists for chunked processing. What the agent supplied was the axis to cut on and the decision to synthesise rather than concatenate, neither of which anybody had specified.
Constraining an interface changes behaviour more reliably than instructing it does. Making the contents genuinely unavailable until it has asked how big they are is not something an agent can forget, because the alternative does not exist.
Branching, checkpoints and undo
An agent exploring a problem hits dead ends, and without a way back it does one of three things:
- burns tokens reversing its own moves by hand
- loses the state and starts over
- stays stuck in a bad position because getting out is more expensive than continuing
The mechanism is one table. Every state-changing call gets a row carrying the state before it and the state after:
export interface MutationRecord {
id: number;
branch: string;
object_id: string;
object_name: string;
method: string;
args: string; // JSON
before_state: string; // JSON
after_state: string; // JSON
created_at: number;
}Once that table exists, everything else is a query against it.
- a checkpoint is a marked row
- an undo reverts the last n mutations by writing
before_stateback - a branch is the
branchcolumn, so forking the timeline at a row lets two strategies run from the same position
The maze solver is the demonstration. The agent gets look, move, checkpoint and restore, and
the checkpoint tool's description tells it to save before exploring a path that might be a dead end.
In the run recorded at the time it escaped in twelve moves after exploring six dead ends, using six
checkpoints and four restores.
The loop in that maze is decide, observe, update, decide again, which is a search in the ordinary computer science sense, solved in first-year courses by the same loop.
Every search has a way of backing up: recursion unwinds the stack, iterative versions keep an explicit one, and the method depends on abandoning a path and resuming from where it forked. An agent in a loop has no stack to unwind. Until you hand it checkpoints, it is running a search with the back-up step deleted, which is a different and much worse algorithm.
What it cost
The package was deleted in a refactor that changed what the substrate was, under a principle recorded at the time:
"Linux already solved these issues." The operating system isn't something to build from scratch — it's something to compose on top of existing infrastructure.
Under that principle the agent is the shell and the filesystem is the store, and the three things objectenv supplies as a runtime are already there:
- persistent objects, because files persist, are addressable by path, and can be passed around by name
- branching and checkpoints, because git has them
- the mutation log, for the same reason
So the runtime was a second runtime sitting on top of one that already worked, and it went. The part
with no equivalent underneath is the constraint methods, which are a design pattern rather than a
package: a peek that structurally cannot return an entry is not something a filesystem gives you,
and it is not something a filesystem prevents you from writing.
I would build the constraint methods again tomorrow, on top of the filesystem rather than on a runtime of my own.