21 Aug 2025

feedPlanet Lisp

TurtleWare: Using Common Lisp from inside the Browser

Table of Contents

  1. Scripting a website with Common Lisp
  2. JS-FFI - low level interface
  3. LIME/SLUG - interacting from Emacs
  4. Injecting CL runtime in arbitrary websites
  5. Current Caveats
  6. Funding

Web Embeddable Common Lisp is a project that brings Common Lisp and the Web Browser environments together. In this post I'll outline the current progress of the project and provide some technical details, including current caveats and future plans.

It is important to note that this is not a release and none of the described APIs and functionalities is considered to be stable. Things are still changing and I'm not accepting bug reports for the time being.

The source code of the project is available: https://fossil.turtleware.eu/wecl/.

Scripting a website with Common Lisp

The easiest way to use Common Lisp on a website is to include WECL and insert script tags with a type "text/common-lisp". When the attribute src is present, then first the runtime loads the script from that url, and then it executes the node body. For example create and run this HTML document from localhost:

<!doctype html>
<html>
  <head>
    <title>Web Embeddable Common Lisp</title>
    <link rel="stylesheet" href="https://turtleware.eu/static/misc/wecl-20250821/easy.css" />
    <script type="text/javascript" src="https://turtleware.eu/static/misc/wecl-20250821/boot.js"></script>
    <script type="text/javascript" src="https://turtleware.eu/static/misc/wecl-20250821/wecl.js"></script>
  </head>
  <body>
    <script type="text/common-lisp" src="https://turtleware.eu/static/misc/wecl-20250821/easy.lisp" id='easy-script'>
(defvar *div* (make-element "div" :id "my-ticker"))
(append-child [body] *div*)

(dotimes (v 4)
  (push-counter v))

(loop for tic from 6 above 0
      do (replace-children *div* (make-paragraph "~a" tic))
         (js-sleep 1000)
      finally (replace-children *div* (make-paragraph "BOOM!")))

(show-script-text "easy-script")
    </script>
  </body>
</html>

We may use Common Lisp that can call to JavaScript, and register callbacks to be called on specified events. The source code of the script can be found here:

Because the runtime is included as a script, the browser will usually cache the ~10MB WebAssembly module.

JS-FFI - low level interface

The initial foreign function interface has numerous macros defining wrappers that may be used from Common Lisp or passed to JavaScript.

Summary of currently available operators:

Summary of argument types:

type name lisp side js side
:object Common Lisp object Common Lisp object reference
:js-ref JavaScript object reference JavaScript object
:fixnum fixnum (coercible) fixnum (coercible)
:symbol symbol symbol (name inlined)
:string string (coercible) string (coercible)
:null nil null

All operators, except for LAMBDA-JS-CALLBACK have a similar lambda list:

(DEFINE-JS NAME-AND-OPTIONS [ARGUMENTS [,@BODY]])

The first argument is a list (name &key js-expr type) that is common to all defining operators:

For example:

(define-js-variable ([document] :js-expr "document" :type :symbol))
;; document
(define-js-object ([body] :js-expr "document.body" :type :js-ref))
;; wecl_ensure_object(document.body) /* -> id   */
;; wecl_search_object(id)            /* -> node */

The difference between a variable and an object in JS-FFI is that variable expression is executed each time when the object is used (the expression is inlined), while the object expression is executed only once and the result is stored in the object store.

The second argument is a list of pairs (name type). Names will be used in the lambda list of the operator callable from Common Lisp, while types will be used to coerce arguments to the type expected by JavaScript.

(define-js-function (parse-float :js-expr "parseFloat" :type :js-ref)
    ((value :string)))
;; parseFloat(value)

(define-js-method (add-event-listener :js-expr "addEventListener" :type :null)
    ((self :js-ref)
     (name :string)
     (fun :js-ref)))
;; self.addEventListener(name, fun)

(define-js-getter (get-inner-text :js-expr "innerText" :type :string)
    ((self :js-ref)))
;; self.innerText

(define-js-setter (set-inner-text :js-expr "innerText" :type :string)
    ((self :js-ref)
     (new :string)))
;; self.innerText = new

(define-js-accessor (inner-text :js-expr "innerText" :type :string)
    ((self :js-ref)
     (new :string)))
;; self.innerText
;; self.innerText = new

(define-js-script (document :js-expr "~a.forEach(~a)" :type :js-ref)
    ((nodes :js-ref)
     (callb :object)))
;; nodes.forEach(callb)

The third argument is specific to callbacks, where we define Common Lisp body of the callback. Argument types are used to coerce values from JavaScript to Common Lisp.

(define-js-callback (print-node :type :object)
    ((elt :js-ref)
     (nth :fixnum)
     (seq :js-ref))
  (format t "Node ~2d: ~a~%" nth elt))

(let ((start 0))
  (add-event-listener *my-elt* "click"
                      (lambda-js-callback :null ((event :js-ref)) ;closure!
                        (incf start)
                        (setf (inner-text *my-elt*)
                              (format nil "Hello World! ~a" start)))

Note that callbacks are a bit different, because define-js-callback does not accept js-expr option and lambda-js-callback has unique lambda list. It is important for callbacks to have an exact arity as they are called with, because JS-FFI does not implement variable number of arguments yet.

Callbacks can be referred by name with an operator (js-callback name).

LIME/SLUG - interacting from Emacs

While working on FFI I've decided to write an adapter for SLIME/SWANK that will allow interacting with WECL from Emacs. The principle is simple: we connect with a websocket to Emacs that is listening on the specified port (i.e on localhost). This adapter uses the library emacs-websocket written by Andrew Hyatt.

It allows for compiling individual forms with C-c C-c, but file compilation does not work (because files reside on a different "host"). REPL interaction works as expected, as well as SLDB. The connection may occasionally be unstable, and until Common Lisp call returns, the whole page is blocked. Notably waiting for new requests is not a blocking operation from the JavaScript perspective, because it is an asynchronous operation.

You may find my changes to SLIME here: https://github.com/dkochmanski/slime/, and it is proposed upstream here: https://github.com/slime/slime/pull/879. Before these changes are merged, we'll patch SLIME:

;;; Patches for SLIME 2.31 (to be removed after the patch is merged).
;;; It is assumed that SLIME is already loaded into Emacs.
(defun slime-net-send (sexp proc)
  "Send a SEXP to Lisp over the socket PROC.
This is the lowest level of communication. The sexp will be READ and
EVAL'd by Lisp."
  (let* ((payload (encode-coding-string
                   (concat (slime-prin1-to-string sexp) "\n")
                   'utf-8-unix))
         (string (concat (slime-net-encode-length (length payload))
                         payload))
         (websocket (process-get proc :websocket)))
    (slime-log-event sexp)
    (if websocket
        (websocket-send-text websocket string)
      (process-send-string proc string))))

(defun slime-use-sigint-for-interrupt (&optional connection)
  (let ((c (or connection (slime-connection))))
    (cl-ecase (slime-communication-style c)
      ((:fd-handler nil) t)
      ((:spawn :sigio :async) nil))))

Now we can load the LIME adapter opens a websocket server. The source code may be downloaded from https://turtleware.eu/static/misc/wecl-20250821/lime.el:

;;; lime.el --- Lisp Interaction Mode for Emacs -*-lexical-binding:t-*-
;;; 
;;; This program extends SLIME with an ability to listen for lisp connections.
;;; The flow is reversed - normally SLIME is a client and SWANK is a server.

(require 'websocket)

(defvar *lime-server* nil
  "The LIME server.")

(cl-defun lime-zipit (obj &optional (start 0) (end 72))
  (let* ((msg (if (stringp obj)
                  obj
                (slime-prin1-to-string obj)))
         (len (length msg)))
    (substring msg (min start len) (min end len))))

(cl-defun lime-message (&rest args)
  (with-current-buffer (process-buffer *lime-server*)
    (goto-char (point-max))
    (dolist (arg args)
      (insert (lime-zipit arg)))
    (insert "\n")
    (goto-char (point-max))))

(cl-defun lime-client-process (client)
  (websocket-conn client))

(cl-defun lime-process-client (process)
  (process-get process :websocket))

;;; c.f slime-net-connect
(cl-defun lime-add-client (client)
  (lime-message "LIME connecting a new client")
  (let* ((process (websocket-conn client))
         (buffer (generate-new-buffer "*lime-connection*")))
    (set-process-buffer process buffer)
    (push process slime-net-processes)
    (slime-setup-connection process)
    client))

;;; When SLIME kills the process, then it invokes LIME-DISCONNECT hook.
;;; When SWANK kills the process, then it invokes LIME-DEL-CLIENT hook.
(cl-defun lime-del-client (client)
  (when-let ((process (lime-client-process client)))
    (lime-message "LIME client disconnected")
    (slime-net-sentinel process "closed by peer")))

(cl-defun lime-disconnect (process)
  (when-let ((client (lime-process-client process)))
    (lime-message "LIME disconnecting client")
    (websocket-close client)))

(cl-defun lime-on-error (client fun error)
  (ignore client fun)
  (lime-message "LIME error: " (slime-prin1-to-string error)))

;;; Client sends the result over a websocket. Handling responses is implemented
;;; by SLIME-NET-FILTER. As we can see, the flow is reversed in our case.
(cl-defun lime-handle-message (client frame)
  (let ((process (lime-client-process client))
        (data (websocket-frame-text frame)))
    (lime-message "LIME-RECV: " data)
    (slime-net-filter process data)))

(cl-defun lime-net-listen (host port &rest parameters)
  (when *lime-server*
    (error "LIME server has already started"))
  (setq *lime-server*
        (apply 'websocket-server port
               :host host
               :on-open    (function lime-add-client)
               :on-close   (function lime-del-client)
               :on-error   (function lime-on-error)
               :on-message (function lime-handle-message)
               parameters))
  (unless (memq 'lime-disconnect slime-net-process-close-hooks)
    (push 'lime-disconnect slime-net-process-close-hooks))
  (let ((buf (get-buffer-create "*lime-server*")))
    (set-process-buffer *lime-server* buf)
    (lime-message "Welcome " *lime-server* "!")
    t))

(cl-defun lime-stop ()
  (when *lime-server*
   (websocket-server-close *lime-server*)
   (setq *lime-server* nil)))

After loading this file into Emacs invoke (lime-net-listen "localhost" 8889). Now our Emacs listens for new connections from SLUG (the lisp-side part adapting SWANK, already bundled with WECL). There are two SLUG backends in a repository:

Now you can open a page listed here and connect to SLIME:

<!doctype html>
<html>
  <head>
    <title>Web Embeddable Common Lisp</title>
    <link rel="stylesheet" href="easy.css" />
    <script type="text/javascript" src="https://turtleware.eu/static/misc/wecl-20250821/boot.js"></script>
    <script type="text/javascript" src="https://turtleware.eu/static/misc/wecl-20250821/wecl.js"></script>
    <script type="text/common-lisp" src="https://turtleware.eu/static/misc/wecl-20250821/slug.lisp"></script>
    <script type="text/common-lisp" src="https://turtleware.eu/static/misc/wecl-20250821/wank.lisp"></script>
    <script type="text/common-lisp" src="https://turtleware.eu/static/misc/wecl-20250821/easy.lisp">
      (defvar *connect-button* (make-element "button" :text "Connect"))
      (define-js-callback (connect-to-slug :type :null) ((event :js-ref))
        (wank-connect "localhost" 8889)
        (setf (inner-text *connect-button*) "Crash!"))
      (add-event-listener *connect-button* "click" (js-callback connect-to-slug))
      (append-child [body] *connect-button*)
    </script>
  </head>
  <body>
  </body>
</html>

This example shows an important limitation - Emscripten does not allow for multiple asynchronous contexts in the same thread. That means that if Lisp call doesn't return (i.e because it waits for input in a loop), then we can't execute other Common Lisp statements from elsewhere because the application will crash.

Injecting CL runtime in arbitrary websites

Here's another example. It is more a cool gimmick than anything else, but let's try it. Open a console on this very website (on firefox C-S-i) and execute:

function inject_js(url) {
    var head = document.getElementsByTagName('head')[0];
    var script = document.createElement('script');
    head.appendChild(script);
    script.type = 'text/javascript';
    return new Promise((resolve) => {
        script.onload = resolve;
        script.src = url;
    });
}

function inject_cl() {
    wecl_eval('(wecl/impl::js-load-slug "https://turtleware.eu/static/misc/wecl-20250821")');
}

inject_js('https://turtleware.eu/static/misc/wecl-20250821/boot.js')
    .then(() => {
        wecl_init_hooks.push(inject_cl);
        inject_js('https://turtleware.eu/static/misc/wecl-20250821/wecl.js');
    });

With this, assuming that you've kept your LIME server open, you'll have a REPL onto uncooperative website. Now we can fool around with queries and changes:

(define-js-accessor (title :js-expr "title" :type :string)
  ((self :js-ref)
   (title :string)))

(define-js-accessor (background :js-expr "body.style.backgroundColor" :type :string)
  ((self :js-ref)
   (background :string)))

(setf (title [document]) "Write in Lisp!")
(setf (background [document]) "#aaffaa")

Current Caveats

The first thing to address is the lack of threading primitives. Native threads can be implemented with web workers, but then our GC wouldn't know how to stop the world to clean up. Another option is to use cooperative threads, but that also won't work, because Emscripten doesn't support independent asynchronous contexts, nor ECL is ready for that yet.

I plan to address both issues simultaneously in the second stage of the project when I port the runtime to WASI. We'll be able to use browser's GC, so running in multiple web workers should not be a problem anymore. Unwinding and rewinding the stack will require tinkering with ASYNCIFY and I have somewhat working green threads implementation in place, so I will finish it and upstream in ECL.

Currently I'm focusing mostly on having things working, so JS and CL interop is brittle and often relies on evaluating expressions, trampolining and coercing. That impacts the performance in a significant way. Moreover all loaded scripts are compiled with a one-pass compiler, so the result bytecode is not optimized.

There is no support for loading cross-compiled files onto the runtime, not to mention that it is not possible to precompile systems with ASDF definitions.

JS-FFI requires more work to allow for defining functions with variable number of arguments and with optional arguments. There is no dynamic coercion of JavaScript exceptions to Common Lisp conditions, but it is planned.

Funding

This project is funded through NGI0 Commons Fund, a fund established by NLnet with financial support from the European Commission's Next Generation Internet program. Learn more at the NLnet project page.

NLnet foundation logo NGI Zero Logo

21 Aug 2025 12:00am GMT

19 Aug 2025

feedPlanet Lisp

Scott L. Burson: FSet 1.5.0 gets custom orderings!

The ordering of the "setlike" collections - sets, maps, and bags - in FSet has always been determined by the generic function fset:compare. This approach is often very convenient, as it allows you to define the ordering of a new type simply by adding a method on compare; there is no need to supply the ordering explicitly every time you create a new collection.

However, as people have complained from time to time, it is also a bit limiting. Say you want to make something like a telephone directory (anyone remember telephone directories?) which maps string keys to values, and you would like it maintained in lexicographic order of the keys. To do this with FSet, you have heretofore had to define a wrapper class, and then a compare method on that class, something like:

(defstruct lexi-string
value)
(defmethod compare ((a lexi-string) (b lexi-string))
(compare-lexicographically (lexi-string-value a) (lexi-string-value b)))

Then you would have to wrap your keys in lexi-strings before adding them to your map. That seems a little wasteful of both time and space.

A second problem with always using fset:compare is that you have to pay the cost of the generic function dispatch several times every time the collection gets searched for an element, as in contains? on a set or lookup on a map. (The number of such calls is roughly the base-2 logarithm of the size of the collection.) One micro-benchmark I ran showed this cost to be around 20% of the access time, which is not insignificant.

So, in response to popular demand, I have added custom orderings to FSet: you can supply your own comparison functions when creating collections, and FSet will call those instead of compare. Use of this feature is completely optional; existing code is not affected. But if you want to do it, now you can!

I refer you to the PR description for the details.

There is one aspect of this change that might surprise you. When given objects of different classes, fset:compare doesn't compare the contents of the objects; it just compares their class names and returns :less or :greater accordingly. So, for instance, a list cannot be equal? to a vector or seq, even if they have the same elements in the same order. This rule now also covers cases where the objects are collections of the same kind (sets, bags, or maps) but with different orderings. So just as a wb-set and a ch-set can never be :equal, so two wb-sets with different orderings can never be :equal; compare will just look at the comparison function names to impose an artificial ordering.

I'm not suggesting this is an ideal situation, but I don't see a way around it. Since comparing two wb-sets of the same ordering relies on that ordering, a combined relation on wb-sets of different orderings would in general fail to be transitive; you would get situations where a < b and b < c, but c < a.

19 Aug 2025 8:59am GMT

16 Aug 2025

feedPlanet Lisp

Joe Marshall: Dinosaurs

What did the dinosaurs think in their twilight years as their numbers dwindled and small scurrying mammals began to challenge their dominance? Did they reminisce of the glory days when Tyrannosaurus Rex ruled the land and Pteranodon soared through the air? Probably not. They were, after all, just dumb animals.

Our company has decided to buy in to Cursor as an AI coding tool. Cursor is one of many AI coding tools that have recently been brought to market, and it is a fine tool. It is based on a fork of VSCode and has AI coding capabilities built in to it. One of the more useful ones (and one that is available in many other AI tools) is AI code completion. This anticipates what you are going to type and tries to complete it for you. It gets it right maybe 10-20% of the time if you are lucky, and not far wrong maybe 80% of the time. You can get into a flow where you reflexively keep or discard its suggestions or accept the near misses and then correct them. This turns out to be faster than typing everything yourself, once you get used to it. It isn't for everyone, but it works for me.

Our company has been using GitHub Copilot for several months now. There is an Emacs package that allows you to use the Copilot code completion in Emacs, and I have been using it for these past few months. In addition to code completion, it will complete sentences and paragraphs in text mode and html mode. I generally reject its suggestions because it doesn't phrase things the way I prefer, but I really like seeing the suggestions as I type. It offers an alternative train of thought that I can mull over. If the suggestions wildly diverge from what I am thinking, it is usually because I didn't lay the groundwork for my train of thought, so I can go back and rework my text to make it clearer. It seems to make my prose more focused.

But now comes Cursor, and it has one big problem. It is a closed proprietary tool with no API or SDK. It won't talk to Emacs. So do I abandon Emacs and jump on the Cursor bandwagon, or do I stick with Emacs and miss out on the latest AI coding tools? Is there really a question? I've been using Emacs since before my manager was born, and I am not about to give it up now. My company will continue with a few GitHub Copilot licenses for those that have a compelling reason to not switch to Cursor, and I think Emacs compatibility is pretty compelling.

But no one uses Emacs and Lisp anymore but us dinosaurs. They all have shiny new toys like Cursor and Golang. I live for the schadenfreude of watching the gen Z kids rediscover and attempt to solve the same problems that were solved fifty years ago. The same bugs, but the tools are now clumsier.

16 Aug 2025 3:04pm GMT

12 Aug 2025

feedPlanet Lisp

Joe Marshall: How Did I Possibly Break This?

It made no sense. My change to upgrade the Java Virtual Machine caused a number of our builds to stop working. But when I investigated, I found that the builds were failing in tsc, the TypeScript compiler. The TypeScript compiler isn't written Java. Java isn't involved in the tool chain. What was going on?

It turned out that someone pushed an update to a TypeScript library simultaneously (but purely coincidentally) with my Java upgrade. The code was written to use the latest library and our TypeScript compiler was past its prime. It barfed on the new library. Java was not involved in the least. It only looked causal because breakage happened right after I pushed the new image.

12 Aug 2025 7:00am GMT

Joe Marshall: Why LLMs Suck at Lisp

In my experiments with vibe coding, I found that LLMs (Large Language Models) struggle with Lisp code. I think I know why.

Consider some library that exposes some resources to the programmer. It has an AllocFoo function that allocates a Foo object, and a FreeFoo function that frees it. The library his bindings in several languages, so maybe there is a Python binding, a C binding, etc. In these languages, you'll find that functions that call AllocFoo often call FreeFoo within the same function. There are a lot of libraries that do this, and it is a common pattern.

Documents, such as source code files, can be thougth of as "points" in a very high dimensional space. Source code files in a particular language will be somewhat near each other in a region of this space. But within the region of space that contains source code in some language, there will be sub-regions that exhibit particular patterns. There will be a sub-region that contains Alloc/Free pairs. This sub-region will be displaced from the center of the region for the language. But here's the important part: in each language, independent of the particulars of the language, the subregion that contains Alloc/Free pairs will be displaced in roughly the same direction. This is how the LLM can learn to recognize the pattern of usage across different languages.

When we encounter a new document, we know that if it is going to contain an Alloc/Free pair, it is going to be displaced in the same direction as other documents that contain such pairs. This allows us to pair up Alloc/Free calls in code we have never seen before in languages we have never seen before.

Now consider Lisp. In Lisp, we have a function that allocates a foo object, and a function that frees it. The LLM would have no problem pairing up alloc-foo and free-foo in Lisp. But Lisp programmers don't do that. They write a with-foo macro that contains an unwind-protect that frees the foo when the code is done. The LLM will observe the alloc/free pair in the source code of the macro - it looks like your typical alloc/free pair - but then you use the macro everywhere instead of the explicit calls to Alloc/Free. The LLM doesn't know this abstraction pattern. People don't write with-foo macros or their equivalents in other languages, so the LLM doesn't have a way to recognize the pattern.

The LLM is good at recognizing patterns, and source code typically contains a lot of patterns, and these patterns don't hugely vary across curly-brace languages. But when a Lisp programmer sees a pattern, he abstracts it and makes it go away with a macro or a higher-order function. People tend not to do that in other languages (largely because either the language cannot express it or it is insanely cumbersome). The LLM has a much harder time with Lisp because the programmers can easily hide the patterns from it.

I found in my experiments that the LLMs would generate Lisp code that would allocate or initialize a resource and then add deallocation and uninitialization code in every branch of the function. It did not seem to know about the with-… macros that would abstract this away.

12 Aug 2025 12:48am GMT

10 Aug 2025

feedPlanet Lisp

Joe Marshall: LLM in the Debugger

There is one more place I thought I could integrate the LLM with Lisp and that is in the debugger. The idea is to have the LLM have gander at the error and suggest a fix before you get dropped into the debugger as usual. The mechanism is pretty straightforward. You use the debugger hook to call the LLM with the error message and a backtrace and tell it you'd like a diagnosis and a suggested fix. You also tell it that it can use its tools to inspect the Lisp environment. Then you cross your fingers and hope that the LLM has some luck. At the very least you get a second opinion on your error.

I had some difficulty with this because SLIME smashes the *debugger-hook* on each interaction. Eventually I settled for a macro that you can wrap your code with. with-llm-debugger binds *debugger-hook* to the LLM debugger before running the body of the macro. Let's see it in action:

(defun my-average (list) (/ (reduce #'+ list) (length list)))

Now let's make the mistake of thinking it takes a &rest argument:

> (with-llm-debugger (my-average 2 3 4))
; in: GEMINI::WITH-LLM-DEBUGGER (MY-AVERAGE 2 3 4)
;     (MY-AVERAGE 2 3 4)
; 
; caught STYLE-WARNING:
;   The function MY-AVERAGE is called with three arguments, but wants exactly one.
; 
; compilation unit finished
;   caught 1 STYLE-WARNING condition
An unhandled error condition has been signalled: invalid number of arguments: 3

;;
;; **Troubleshooting a Lisp Argument Error**
;;
;; Okay, so I've got this Common Lisp error message: "invalid number of arguments:
;; 3".  And the backtrace pinpoints `MY-AVERAGE` as the culprit, called with the arguments
;; `2 3 4`. Hmm, that's pretty straightforward. The function `MY-AVERAGE` was called
;; with three arguments, but it wasn't expecting them.
;;
;; The next step, naturally, is to figure out what `MY-AVERAGE` *does* expect.  Since
;; I don't have direct access to the user's code, I'll need to query the Lisp environment
;; itself. The best way to do that is to use the `default_api.describe` function on
;; the symbol `MY-AVERAGE`. This should give me its definition, which will include
;; its expected argument list.
;;
;; Once I have that argument list, the fix should be obvious.  Either the arguments
;; being passed are wrong, or the `MY-AVERAGE` function needs a definition adjustment.
;;  I'm ready to proceed, query the environment, and get to the root of this.
;;
;; Processing function call: describe

The LLM has decided to call into Lisp. The describe function is a tool that is just a wrapper around the Lisp describe function. It takes a symbol and returns whatever the Lisp describe function returns.

;; **Understanding the `MY-AVERAGE` Function Call Error**
;;
;; Okay, so looking at this, it seems the problem is pretty straightforward. `describe`
;; clearly tells me that `MY-AVERAGE` is a compiled function and that it's expecting
;; a single argument, which it's calling `LIST`. I can see from the backtrace that
;; I attempted to call it with the individual numbers 2, 3, and 4. The error message
;; makes perfect sense now. I provided three separate arguments, but the function's
;; definition only allows for a single argument: a list.
;;
;; Essentially, I need to wrap those numbers in a list before passing them to the
;; function. Instead of calling it with `(MY-AVERAGE 2 3 4)`, I should have been using
;; `(MY-AVERAGE '(2 3 4))`. That way, the function receives a single list containing
;; the elements it expects. A classic case of mismatched expectations between function
;; definition and invocation! I'll update the calling code and that should resolve
;; the issue.
;;

So those are the LLM's "thoughts" on the error. Here is its diagnosis and conclusion:

The error "invalid number of arguments: 3" for `MY-AVERAGE`
occurred because the `MY-AVERAGE` function expects a single argument,
which should be a list, as indicated by its lambda list
`(LIST)`. However, it was called with three separate arguments: `2`,
`3`, and `4`.

**Diagnosis:**
The function `MY-AVERAGE` is defined as:

```commonlisp
(LAMBDA (LIST)
  (BLOCK MY-AVERAGE (/ (REDUCE #'+ LIST) (LENGTH LIST))))
```
;; Prompt Tokens:       4316
;; Thoughts Tokens:      227
;; Candidate Tokens:     223
;; Total Tokens:        4766
;; Prompt Tokens:       3959
;; Thoughts Tokens:      209
;; Candidate Tokens:      16
;; Total Tokens:        4184
LLM Debugger Response: 
This definition clearly shows that `MY-AVERAGE` is intended to take a single argument named `LIST`.

**Suggested Fix:**
To correctly use `MY-AVERAGE` to calculate the
average of 2, 3, and 4, you need to pass these numbers as a single
list argument.

Change the call from:
`(MY-AVERAGE 2 3 4)`

To:
`(MY-AVERAGE '(2 3 4))`

Not too bad, I guess. But I'd have to put it through its paces on real errors before I could say how useful it is.

10 Aug 2025 12:29pm GMT

09 Aug 2025

feedPlanet Lisp

Joe Marshall: REPL + Prompt

I'm interested in LLMs and I'm into Lisp, so I wanted to explore ways to combine the two. Earlier, I wrote a pseudocode macro that uses an LLM to expand pseudocode into Common Lisp code. I also wrote an autodoc feature that uses an LLM to generate docstrings if you don't provide them yourself. These are two examples of Lisp calling into the LLM. Naturally, I wanted to see what would happen if we let the LLM call into Lisp.

We can provide Lisp "tools" to the LLM so that it can have an API to the client Lisp. Some of these tools are simply extensions to the LLM that happen to written in Lisp. For example, the random number generator. We can expose user interaction tools such as y-or-n-p to allow the LLM to ask simple y/n questions. But it is interesting to add Lisp introspection tools to the LLM so it can probe the Lisp runtime.

There is an impedance mismatch between Lisp and the LLM. In Lisp, data is typed. In the LLM, the tools are typed. In Lisp, a function can return whatever object it pleases and the object carries its own type. An LLM tool, however, must be declared to return a particular type of object and must return an object of that type. We cannot expose functions with polymorphic retun values to the LLM because we would have to declare the return type prior to calling the function. Furthermore, Lisp has a rich type system compared to that of the LLM. The types of many Lisp objects cannot easily be declared to the LLM.

We're going to live dangerously and attempt to give the LLM the ability to call eval. eval is the ultimate polymorphic function in that it can return any first-class object. There is no way to declare a return type for eval. There is also no way to declare the argument type of s-expression. Instead, we declare eval to operate on string representations. We provide a tool that takes a string and calls read-from-string on it, calls eval on the resulting s-expression, and calls print on the return value(s). The LLM can then call this tool to evaluate Lisp expressions. Since I'm not completely insane, I put in a belt and suspenders check to make sure that the LLM does not do something I might regret. First, the LLM is instructed to get positive confirmation from the user before evaluating anything that might have a permanent effect. Second, the tool makes a call to yes-or-no-p before the actual call to eval. You can omit this call to yes-or-no-p by setting *enable-eval* to :YOLO.

It was probably obvious to everyone else, but it took me a bit to figure out that maybe it would be interesting to have a modified REPL integrated with the LLM. If you enter a symbol or list at the REPL prompt, it would be sent to the evaluator as usual, but if you entered free-form text, it could be sent to the LLM. When the LLM has the tools capable of evaluating Lisp expressions, it can call back into Lisp, so you can type things like "what package am I in?" to the modified REPL, and it will generate a call to evaluate "(print *package*)". Since it has access to your Lisp runtime, the LLM can be a Lisp programming assistant.

The modified REPL has a trick up its sleeve. When it gets a lisp expression to evaluate, it calls eval, but it pushes a fake call from the LLM to eval on to the LLM history. If a later prompt to is given to the LLM it can see this call in its history - it appears to the LLM as if it had made the call. This allows the LLM to refer to the user's interactions with Lisp. For example,

;; Normal evaluation
> (+ 2 3)
5

;; LLM command
> add seven to that
12

The LLM sees a call to eval("(+ 2 3)") resulting in "5" in its history, so it is able to determine that the pronoun "that" in the second call refers to that result.

Integrating the LLM with the REPL means you don't have to switch windows or lose context when you want to switch between the tools. This streamlines your workflow.

09 Aug 2025 8:47am GMT

08 Aug 2025

feedPlanet Lisp

Joe Marshall: Autodoc

The opposite of pseudocode is autodoc. If pseudocode is about generating code from a text description, then autodoc is about generating a text description from code. We shadow the usual Common Lisp defining symbols that take docstrings, such as defun, defgeneric, defvar, etc. and check if the docstring was supplied. If so, it is used as is, but if the docstring is missing, we ask the LLM to generate one for us. Your code becomes self-documenting in the truest sense of the word.

I have added autodoc as an adjunct to the pseudo system. It uses the same LLM client (at the moment Gemini) but the system instructions are tailored to generate docstrings. Here are some examples of source code and the generated docstrings:

(defclass 3d-point ()
  ((x :initarg :x :initform 0)
   (y :initarg :y :initform 0)
   (z :initarg :z :initform 0)))

;;; Generated docstring:
"A class representing a point in 3D space with x, y, and z coordinates."

(defconstant +the-ultimate-answer+ 42)

;;; Generated docstring:
"A constant representing the ultimate answer to life, the universe, and everything."

(defgeneric quux (a b)
  (:method ((a int) (b int))
    (declare (ignore a b))
    0)
  (:method ((a string) (b string))
    (declare (ignore a b))
    "Hello, world!"))

;;; Generated docstring:
"A generic function that takes two arguments of type int or string.

The int method returns 0, and the string method returns 'Hello, world!'."

(defmacro bar (a b)
  `(foo ,a ,b))

;;; Generated docstring:
"A macro that expands to a call to the function foo with arguments a and b."

(defparameter *screen-width* 640)

;;; Generated docstring:
"A global variable representing the width of the screen in pixels."  

(defstruct point
  (x 0)
  (y 0))

;;; Generated docstring:
"A structure representing a point in 2D space with x and y coordinates."

(defun foo (a b)
  (+ a b))

;;; Generated docstring:
"A function that takes two arguments a and b and returns their sum."

(defvar *current-foo* nil)

;;; Generated docstring:
"A global variable that holds the current value of foo, initialized to nil."

As you can see, the generated docstrings aren't bad. They describe the purpose of the class, constant, generic function, macro, global variable, and structure. The docstrings are not perfect, but they are better than nothing, which is what you start with.

08 Aug 2025 5:39am GMT

06 Aug 2025

feedPlanet Lisp

Joe Marshall: My Sources Say No

You'd think an LLM would be able to pick something at random. Well, maybe you wouldn't, but I sure did. The temperature setting is supposed to influence the randomness of the results, right? So I was amusing myself with this prompt:

Generate a random response typical of a magic eight ball.

And I got this:

As a large language model, I can't generate a random response like a magic eight ball. I don't have a mechanism for true randomness or a pre-programmed set of \"magic eight ball\" answers.

Well, that's surprising. A cynical person might draw an analogy between an LLM and a magic eight ball with a billion answers. Oversimplified, of course, but the LLM is not determistic. And you'd think that if it didn't have a pre-programmed set of "magic eight ball" answers that it could make some up.

Let's give it some help. I added a random number generator as a tool to the LLM and turned on includeThoughts.

;; The beauty of this is in the simplicity. The Magic Eight Ball has a finite, predictable
;; set of answers. Therefore, I can decide on a few responses, define them, and then
;; have `randomInteger` choose one for me.  For now, let's keep it simple and define
;; just three responses. This means I'll need to use `randomInteger(max=3)` to get
;; a number between 0 and 2.  Easy peasy.

It is certain.

Yes, well, that's the problem, isn't it. It works best if you tell it how many canned responses you want it choose from.

You are a Zoltar machine with 25 fortunes. Generate a random fortune.

;;
;; **My Zoltar-esque Process**
;;
;; Alright, a user wants a fortune. Time to channel my inner Zoltar! My plan is simple:
;; I need to pick a fortune from a deck of 25. The core of this operation is going
;; to be the `randomInteger` function. I'll use it to generate a number between 1
;; and 25 (inclusive). That number will act as the key to my fortune bank. After I
;; have the key, I'll retrieve the corresponding fortune and present it to the user.
;; Hopefully, it'll be a good one!
;;

A long-held dream will soon come to fruition.

Clearly a random number generator ought to be a stock tool for an LLM.

The thinking output is interesting. It is rather chatty, though, and I'm paying for those tokens. I cannot imagine "Time to channel my inner Zoltar!" is adding much to the generation process.

There's something satisfyingly perverse in using advanced technology to emulate a magic eight ball or Zoltar.

(One of the other tools I often include is a promptAndReadLine tool that prints a prompt to the user, reads a line of input, and returns that to the LLM. I was amused when the magic eight ball responded by prompting "Shake, and ask again later:")

06 Aug 2025 2:25pm GMT

05 Aug 2025

feedPlanet Lisp

Joe Marshall: Recursive Prompting

What if we give the LLM the ability to prompt itself? I added a "tool" to the LLM prompt that allows the LLM to prompt itself by calling the promptLLM function with a string.

I guess it isn't surprising that this creates an infinite loop. The tool appears to have a higher affinity than the token prediction engine, so the LLM will always try to call the tool rather than do the work itself. The result is that the LLM calls the tool, which calls the LLM, which calls the tool, which calls the LLM, etc.

We can easily fix this by not binding the tool in the recursive call to the LLM. The recursive call will not have the tool, so it will engage in the token prediction process. Its results come back to the tool, which passes them back to the calling LLM, which returns the results to us.

Could there be a point to doing this, or is this just some recursive wankery that Lisp hackers like to engage in? Actually, this has some interesting applications. When the tool makes the recursive call, it can pass a different set of generation parameters to the LLM. This could be a different tool set or a different set of system instructions. We could erase the context on the recursive call so that the LLM can generate "cold" responses on purpose. We could also use this to implement a sort of "call-with-current-continuation" on the LLM where we save the current context and then restore it later.

The recursive call to the LLM is not tail recursive, though. Yeah, you knew that was coming. If you tried to use self prompting to set up an LLM state machine, you would eventually run out of stack. A possible solution to this is to set up the LLM client as a trampoline. You'd have some mechanism for the LLM to signal to the LLM client that the returned string is to be used to re-prompt the LLM. Again, you'd have to be careful to avoid infinite self-calls. To avoid accumulating state on a tail call, the LLM client would have to remove the recent context elements so that the "tail prompt" is not a continuation of the previous prompt.

Recursive prompting could also be used to search the prompt space for prompts that produce particular desired results.

If you had two LLMs, you could give each the tools needed to call the other. The LLM could consult a different LLM to get a "second opinion" on some matter. You could give one an "optimistic" set of instructions and the other a "pessimistic" set.

The possibilities for recursive prompting are endless.

05 Aug 2025 7:29pm GMT