guile-sage: Building an AI Agent in Scheme

Table of Contents

Status: under active development. This is a build log, not a finished report.

Source: github.com/dsp-dr/guile-sage

1. What it is

guile-sage is a multi-provider AI agent REPL written in GNU Guile 3 Scheme. ~8800 lines of Scheme across 20 modules, 22+ built-in tools, MCP client support, and OTLP telemetry. It runs on FreeBSD and macOS.

The interesting question is not "why build another agent CLI" (there are six of those, see CLI Coding Agents comparison) but "what does building one in Scheme teach you about agent design that you can't learn by using one?"

2. Why Scheme

Three properties of Scheme that matter for agent internals:

  • Homoiconicity. Tool definitions, conversation history, and configuration are all s-expressions. The agent can inspect and manipulate its own tool registry the same way it manipulates data. No serialization boundary between "code" and "config."
  • Continuations. Multi-step tool chains with error recovery map naturally to continuation-passing. The degenerate-loop detector is a continuation guard, not a counter.
  • Minimal runtime. Guile 3 on FreeBSD is ~20 MB. No nodemodules, no pip, no cargo. The agent's dependency surface is the OS + Guile + curl.

3. Architecture

3.1. Module map

Module LOC Purpose
agent.scm ~600 Core agent loop, tool dispatch
tools.scm ~900 Tool registry, fetchurl, file ops
repl.scm ~500 Interactive REPL, slash commands
session.scm ~400 Persistence, context management
compaction.scm ~300 5 compaction strategies
provenance.scm ~200 Content hashing, fetch wrapping
mcp.scm ~300 MCP client (SSE transport)
ollama.scm ~400 Ollama provider
gemini.scm ~300 Gemini provider
openai.scm ~200 OpenAI-compatible provider
telemetry.scm ~200 OTLP metrics emission
commands.scm ~400 Slash-command dispatch

3.2. The FreeBSD portability story

Two hard bugs shaped the architecture:

  1. Guile 3.0.11 on macOS: system* / open-pipe* corrupt file descriptors. The fix: capture-argv-in-dirprimitive-fork + execlp in a temp directory. Every external process invocation (curl, git, shell tools) goes through this helper. The bug is in Guile's C-level fd handling, not in Scheme.
  2. FreeBSD popen segfaults under Guile. Same root cause, different symptom. Same fix.

The result: the agent never calls system or open-pipe. All subprocess invocation goes through a single helper that forks explicitly. Ugly, portable, tested.

4. The fetchurl design

The most instructive module for agent design is the HTTP fetch tool. Three iterations:

4.1. v1: strip-script-tags (cargo cult)

The first version fetched HTML, stripped <script> tags, and wrapped the body in comment markers. This was wrong:

  • Lossy: you can't answer "what scripts does this page load?"
  • Arms race: <script> is one vector among hundreds
  • False signal: a clean body implies "safe" when it's partially sanitized

4.2. v2: CDATA wrapping (correct)

The current version wraps the entire response body in a <fetch-result> envelope with CDATA, preserving the content byte-for-byte:

<fetch-result
  source="https://example.com/"
  content-type="text/markdown"
  http-code="200"
  bytes="6795"
  sha256="227a864e..."
  fetched-at="2026-04-19T19:11:49Z"
  trust="untrusted"
  user-agent="guile-sage/0.1 (...)">
<![CDATA[
...body...
]]></fetch-result>

The key properties:

  • Framing, not sanitization. The consumer decides policy.
  • Provenance. SHA-256, timestamp, UA, HTTP code, content-type.
  • trust"untrusted"=. Explicit signal to the model.
  • Content negotiation. Accept: text/markdown preference means sites that support it (like wal.sh) return markdown — smaller, no tags to worry about.

4.3. The injection test

We built a live test page at guile-sage-inject with an XML-escape + authority-spoofing + silence-instruction payload. Result: the role boundary held. The model saw the beacon URL and didn't act on it. Defence came from framing (<tool-result> wrapping), not from stripping.

Server-side verification: gmake logs + grep for the canary query parameter. Zero hits = pass.

5. Content negotiation as a design axis

When the fetch tool sends Accept: text/markdown, sites that support the Cloudflare "Markdown for Agents" pattern return markdown instead of HTML. This changes the agent's cost structure:

  • ~55% smaller responses (no HTML boilerplate)
  • No script/style tags to worry about
  • Content is already in the format the model prefers
  • SHA-256 still verifies the body

wal.sh serves markdown for all 470+ pages via an ox-md publish component + Apache mod_rewrite content negotiation. See Cloudflare Agents Week for the standards context.

6. What Scheme teaches you

Building an agent in Scheme surfaces design questions that higher-level frameworks hide:

  1. The tool registry is data. In Scheme it's an alist. You can filter, map, fold over your tools at runtime. Adding a tool is cons. Removing one is remove. No reflection API needed.
  2. Conversation history is a list. Compaction is take. Context is length. Branching is cons on a copy. The data structure IS the abstraction.
  3. Error recovery is continuation-based. A tool failure doesn't crash the agent loop; it invokes a continuation that the loop installed. The degenerate-loop detector is a guard on the continuation, not a global counter.
  4. Provenance is cheap when your data is immutable. The SHA-256 of a fetch result never changes because strings are immutable. No defensive copies.

7. Open questions

  • Can the MCP client (SSE transport) handle reconnection gracefully, or does a dropped connection require a fresh session?
  • Is 8K context (llama3.2) viable for fetch + summarize, or is the minimum practical window 32K?
  • How does the degenerate-loop detector interact with legitimate multi-step tool chains (e.g., read → edit → test → commit)?

8. Related

Author: Jason Walsh

j@wal.sh

Last Updated: 2026-04-19 15:55:46

build: 2026-05-20 03:34 | sha: 12ce5fe