Will ChenWill Chen
← Writingsystem design

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.

Will ChenWill Chen7 min

When you give a language model the ability to act, you give it tools, and 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, which is that the tools have no memory. Each call is complete in itself. Anything the model wants to keep, it keeps by holding it in the conversation and reading it again 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 now carrying its own working memory inside the conversation, and everything it wants to remember has to be re-read on every subsequent turn.

There is a second problem underneath it that took me longer to name. Once an agent is inside its loop it is very hard to use from a program. Say you are writing a script and partway through you want an agent to work something out. You define it, give it tools, let it run until it hits some step limit, and then you want the answer back so the script can carry on. Getting the answer back is awkward, because 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, because the only way to make it do anything is to offer a tool and hope it calls one. And there is no clean way for the agent to hand control back to your program, because the only exit is a tool call, and a tool call continues the loop rather than returning from it.

That last one is worth slowing down on, because it is a plain thing that sounds exotic.

Every function you have ever written can do two things when it finishes. It can call something else, or it can return, which means handing a value back to whoever called it and disappearing. Returning is so ordinary that it has no name in most people's heads; it is just what the last line of a function does.

An agent loop cannot do it. The loop's only move is to emit a tool call, and every tool call feeds the result back into the loop and runs another turn. There is no gesture that means "stop, here is the answer, and give it to the program that started me". You can approximate it by defining a tool called finish and watching for it from outside, which is what everybody does, and it works the way a goto works: fine until you want two of them nested.

Lisp people have a name for the general machinery that makes returning a thing you can control rather than a thing the language does to you, and it is called a continuation. I do not think an agent framework needs the full version. It does need the ordinary half of it, which is a way to end the loop with a value rather than with another message.

What I wanted, then, was for state to sit with the compute rather than in the transcript: objects the model could manipulate, that persisted between calls, and that it could refer to and pass around rather than reconstruct. Over two days in January 2026 I built that and called it objectenv.

The object model

The core type is small enough to read in full. An object class has a name, a description, a default state, and a set of methods, and each method has three parts: an implementation, a description of what it does and when to use it, and an example of calling it.

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 on a method. The example is optional, which turned out to matter, because the methods that needed one were the methods whose call shape was not obvious from the name.

The description and the example are the interesting part, because they are not documentation for a person. They are how the agent finds out what the object can do. 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 before 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 part of this I still think about is a group of methods I marked in the source as constraint methods, because their job is not to do anything useful. Their job is to withhold.

A worklog with two years of entries in it is too big to read. The obvious interface hands the agent a getAll and lets it discover that the hard way. 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 is in the description because the description is what the agent reads, and the reason it holds is that the shape of the return value makes any other order pointless.

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.

So an agent that wants to summarise the whole worklog cannot simply ask for the whole worklog. It has to find out how big the thing is, notice that the number is large, and decide how to cut it up.

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.

I want to be careful about what that does and does not demonstrate, because the interesting version is narrower than the flattering one. The affordances were pointing at chunking: one method says to call it first to understand scale, another says it exists for chunked processing. What the agent supplied was the part nobody had specified, which is the axis to cut on and the decision to synthesise at the end rather than concatenate. The strategy came out of the agent reading the methods available to it, not out of a plan I gave it.

That is still the result I wanted, and it points at something I keep finding in different places. Constraining an interface changes behaviour more reliably than instructing it does. Telling an agent to think about scale is an instruction it can forget by the third tool call. Making the contents genuinely unavailable until it has asked how big they are is not something it can forget, because the alternative does not exist.

Branching, checkpoints and undo

The other half of the system came from watching agents get stuck. An agent exploring a problem hits dead ends, and without a way back it does one of three things: it burns tokens reversing its own moves by hand, it loses the state and starts over, or it stays stuck in a bad position because getting out is more expensive than continuing.

The fix was to start logging every state-changing call with the state before it and the state after, which is the whole mechanism. Once that table exists, everything else is a query against it: a named checkpoint is a marked row, an undo reverts the last n mutations, and a branch forks the timeline at a row so two strategies can be tried 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.

Look at what the agent is doing in that maze and it stops looking like agent behaviour at all. It makes a decision, observes what happened, updates what it believes, and decides again. That is a search, in the ordinary computer science sense, and the mazes in a first-year course are solved by exactly the same loop.

Which is why the fix works, and also why it should have been obvious. Every search algorithm anybody has written has some way of backing up: recursion unwinds the stack for you, iterative versions keep an explicit one, and the whole method depends on being able to abandon a path and resume 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 not a weak search, it is a different and much worse algorithm.

What it cost

Two months later I deleted the package.

The reason is the same one that produced everything I built next. An agent with a filesystem and a shell already has persistent objects, because files persist and are addressable and can be passed around by name. It already has branching and checkpoints, because git has them. It already has an audit log, for the same reason. The thing objectenv was reaching for was mostly available, and the part I had actually invented was the constraint methods, which is a design pattern rather than a package.

I would build the constraint methods again tomorrow. I would not build the runtime under them.