}

What It Means for an Agent to Think About Its Own Actions

What It Means for an Agent to Think About Its Own Actions

Second in a series. The first post, "Your Agent's Tools Are Dead Data," argued that JSON function-calling schemas are inert data that can't participate in reasoning ... and sketched three (increasing) claims. This post defends the middle one: agent action spaces should be homoiconic. If that's wrong, the narrow claim is just a complaint about syntax. If it's right, it reshapes how we think about agent architecture.


The property

Homoiconicity is a property of formal systems where programs are represented as data structures the language itself can manipulate. In Lisp, a program is a list (or more precisely, a nested data literal ... the point generalizes). You can build lists, take them apart, transform them, evaluate them. Code is data. Data can become code. No boundary between the two. What's missing in current agent architectures is a representation that both the model and the runtime treat as the native action language, end to end.

For agents, this matters not as "elegance" but as operational capability. Consider what an agent needs to do beyond simple tool dispatch. It needs to inspect what capabilities are available: not as opaque labels but as structured descriptions it can query and compare. It needs to look at what it's done: not as a log to be summarized in natural language but as structured data it can pattern-match against. It needs to compose existing capabilities into new ones by manipulating capability descriptions directly. It needs to do all this using the same machinery it uses for everything else.

The moment any of these operations requires switching representations (from JSON to Python, from a trace format to a planning format, from structured data to natural language and back) you've introduced a translation boundary. Every such boundary is a place where structure is lost, where the agent must rely on fuzzy pattern matching instead of precise manipulation, where errors compound silently.

Homoiconicity eliminates these boundaries by construction (not by being clever, but by refusing to create them in the first place). The agent never needs to translate between "the description of an action" and "the execution of an action," because both are expressions in the same system.

Role-uniformity

If homoiconicity is the property, role-uniformity is its architectural consequence. In current agent systems, every operational role gets its own format: tool definitions live in JSON schemas, plans are natural-language steps or orchestration-layer objects, results are serialized return data, errors are strings or exception objects, traces are logs. Each has its own shape, its own access patterns, its own manipulation operations.

What if they didn't? Here's what I mean concretely. The following examples use an S-expression notation (and if you haven't seen that before, you can try to read the parenthesized structures as nested key-value data). Consider this tool definition:

(tool fetch-url
  {:params [{:name "url" :type "string" :required true}]
   :returns {:type "string"}
   :effects [:http-get]})

Or this execute request:

(result fetch-url
  {:invocation (fetch-url {:url "https://example.com/data"})
   :value "<html>..."
   :elapsed-ms 340
   :trace-id "a3f8c"})

Or this error:

(error fetch-url
  {:invocation (fetch-url {:url "https://example.com/down"})
   :kind :http-timeout
   :after-ms 5000
   :trace-id "b7d2e"})

The shapes rhyme. They're all expressions: lists with a role tag, a name, and a map of structured data. Any operation that works on one works on all of them.

You can pattern-match across a trace of results and errors using the same operations you'd use to inspect tool definitions. You can ask "which of my tools declare :effects [:http-get]" and "which of my recent errors had :kind :http-timeout" with identical machinery.

This is where composition stops being exceptional. An agent that notices it frequently calls fetch-url, then extract-text, then llm-summarize in sequence can abstract that pattern:

(def research-topic
  (fn [url]
    (do
      (let raw (fetch-url {:url url}))
      (let text (extract-text {:input raw}))
      (llm-summarize {:input text}))))

The new capability is "the same kind of thing" as the primitives it's built from: an expression the agent can evaluate, quote, inspect, or hand to another agent. No external system needed to bridge "description" to "executable." The notation doesn't distinguish between built-in tools and composed ones because structurally there's nothing to distinguish.

The Voyager illusion

The most sophisticated counterargument to all of this is Voyager: the Minecraft agent that writes and accumulates JavaScript functions as a growing skill library. Voyager looks like agent self-modification. The agent encounters a novel situation, writes a new function to handle it, stores it, retrieves it later. If you squint, this is exactly the capability I'm claiming requires homoiconicity.

But look at what's actually happening. The agent generates JavaScript as a string. An external runtime evaluates that string. The agent can retrieve the string later and include it in a prompt, but it cannot inspect the function's structure as data. It can't ask "what tools does this composed function depend on?" or "is this function structurally similar to one I wrote yesterday?" without re-parsing the string through the language model's general-purpose text understanding ... which is fuzzy, expensive, and unreliable for structural queries.

The difference is visible if you imagine the agent trying to refactor. In Voyager, the agent has a skill library containing mine_oak_logmine_birch_log, and mine_spruce_log: three functions that are structurally identical except for the wood type. Can the agent notice the redundancy and factor out a generic mine_log parameterized by wood type? In principle, yes ... by reading the JavaScript source as text and reasoning about it in natural language. In practice, this is asking a language model to be a reliable program-analysis tool, which it isn't.

In a homoiconic notation, those three skills are data: not strings containing code, but expressions with inspectable structure. The agent can structurally compare them (literally diff the expressions!) identify the varying parameter, and produce the abstraction. Not through text understanding. Through the same sequence operations it uses for everything else.

Voyager's skill library is a filing cabinet. The agent can add files and retrieve them by label. Homoiconicity gives you a workbench: a space where skills can be laid out, compared, taken apart, and reassembled. The difference between accumulating behaviors and reflecting on them is the difference between a sophisticated script and something that starts to deserve the word "agent."

This isn't an argument that homoiconicity is sufficient for building good agents ... after all planning, memory, goal decomposition are hard problems a representational choice doesn't solve. The argument is that without structural affordances, progress in this direction is impossible. With them, it becomes a matter of degree.

The "better wire format" trap

When I describe homoiconic action spaces to people building agent frameworks, a common response is: "So you want a better serialization format for tool definitions." The confusion is worth unpacking, because it's diagnostic.

A better wire format (e.g. more canonical JSON, or a schema language with compositional types) improves the description of tools. The agent still receives descriptions, picks from them, and emits invocations that an external system executes. The descriptions are nicer. But the agent is still a consumer of descriptions and a producer of invocations. It operates on the format, not within it. The notation is still inert – just inert at "higher fidelity".

The test: can the agent write a new tool definition and then use it, without any system other than the notation itself mediating the transition from "description" to "executable capability"? In a homoiconic notation, yes: a tool definition is an expression, and expressions evaluate. In a wire format, no matter how sophisticated, something external must interpret the description and wire up the execution.

The distinction between a notation the agent works on and a notation the agent works within is the crux of the homoiconicity claim.

Effects at the boundary

The design principle is one functional programmers recognize: pure core, impure shell. The agent's notation describes what should happen, as structured expressions it can inspect and manipulate. An executor makes it happen. What comes back is a result expressed in the notation.

A reasonable worry follows from this: doesn't the notation need to handle everything? HTTP requests, browser automation, image manipulation, database queries? That sounds like building a general-purpose programming language, which is exactly the kind of scope creep that kills projects.

No. The agent doesn't need the notation to make HTTP requests. It needs the notation to express the intent (as a structured expression) and a mechanism to send that expression to something that can execute it. The execution happens outside. The critical property is that both the request and the response are expressions the agent can inspect and reason about. Effects pass through a gate where they can be logged, audited, and constrained by policy.

This also solves the interop problem that kills most "let's design a new language" projects. The effect executor for HTTP can be written in Python. The executor for browser automation can be in TypeScript. The executor for database queries can be in whatever has the best driver. The agent speaks one notation; the outside world speaks whatever it speaks; the boundary translates.

The environment question

Homoiconicity changes the kind of thing an agent is.

In the current paradigm, an agent is a model called repeatedly by an orchestration loop. The loop (written in Python, TypeScript, whatever) is the real control flow. The model is consulted for decisions but doesn't persist between invocations in any meaningful sense. Its "state" is reconstructed each time from a growing context window.

In a homoiconic architecture, the agent can be a process: something that maintains a live evaluation environment with bindings, defined capabilities, and accumulated abstractions. The difference is like the difference between calling a function and entering a REPL. In one, you provide inputs and receive outputs. In the other, you're inside a live world where your previous actions have shaped the environment you're acting in.

The agent stops being a function that gets called and starts being something that inhabits a live environment. The consequences for agent memory, learning, and governance are real ... but they're topics for later posts.

Scope

The point is diagnostic: if your agent can't inspect its own action definitions as structured data, if it can't compose them into new definitions using the same operations it uses for everything else, if it can't execute those new definitions without external interpretation ... then your architecture has a ceiling. That ceiling becomes visible exactly when you try to build the most interesting kinds of agent behavior.

The next question (whether there's something specific about the Lisp family of properties that makes it the right foundation here, or whether homoiconicity can be achieved equally well in other ways) is one I've been deliberately deferring.

Yes, I have an opinion! But this intermediate claim needs to stand or fall on its own before the ... let's say more speculative one ... gets stacked on top.