Building an AI Coding Assistant for Common Lisp

Earlier chapters built the pieces this one assembles: the litelm chapter introduced the provider-neutral model client, and the previous chapter wrapped a persistent SQLite cache in a small Common Lisp API.

In this chapter we build cl-ai-coding-agent, a focused coding assistant that uses litelm. The library speaks the OpenAI-compatible tool-calling protocol shared by many providers: we declare tools in a plain Lisp list format, the model returns typed tool-call requests, and our agent loop dispatches them directly. This eliminates the fragile JSON parsing layer entirely and lets the model decide autonomously when to inspect directories, read source files, write new files, or simply answer a question.

Because litelm routes calls by a "provider/model" string, the same agent code works with OpenAI, Gemini, Fireworks AI, DeepSeek, or a local Ollama server. The default model is the local Ollama model qwen3.5:4b, so the examples in this chapter run without any API key. Later in the Hacking the SBCL REPL chapter we integrate the agent further with reader macros (#?) and automatic error interception so the agent is always one keystroke away.

Architecture

The agent follows a simple multi-round loop built on litelm:completion:

  1. Prompt augmentation: The user’s input is paired with a system message that describes the available tools. If the input looks like a stacktrace, additional diagnostic instructions are injected into the system message.
  2. Round 1: The message list and tool declarations are sent to the model via litelm:completion. The model returns either a text response (no tools needed) or a list of tool-call requests.
  3. Tool dispatch: Each tool call is executed locally. The agent can list directories, read files, and write files.
  4. Round 2+: The assistant’s tool calls and the tool results are appended to the message history, and the updated history is sent back to the model. The model may request more tool calls (for example, read a file after listing a directory) or return a final text response.
  5. Termination: The loop repeats for up to 10 rounds, then returns whatever text the model has produced.

Here, the OpenAI-compatible tool-calling protocol in the library litelm handles the structure, so we never parse JSON tool invocations ourselves. The model’s tool-call requests arrive as typed plists, and our tool results go back into the message history in the same Lisp format. This is both more reliable and simpler to implement.

Project Structure

The project has three source files plus an ASDF system definition:

File Purpose
package.lisp Package definition and exports
tools.lisp File-system tool implementations and litelm tool definitions
agent.lisp System prompt construction, agent loop, and public API
cl-ai-coding-agent.asd ASDF system definition

Its direct dependencies are the litelm library (developed in the litelm chapter, in the sibling directory ../litelm) and UIOP (which ships with ASDF). litelm itself brings in dexador, cl-json and alexandria, so those must be reachable on the Quicklisp path.

cl-ai-coding-agent.asd

 1 ;;; cl-ai-coding-agent.asd -- ASDF system definition
 2 (in-package #:asdf-user)
 3 
 4 (defsystem "cl-ai-coding-agent"
 5   :version "0.1.0"
 6   :author "Mark Watson <markw@markwatson.com>"
 7   :license "Apache-2.0"
 8   :description
 9   "An AI coding agent that reads directories and files,
10    writes new files, and diagnoses stacktraces."
11   :depends-on ("litelm" "uiop")
12   :components ((:file "package")
13                (:file "tools")
14                (:file "agent")))

package.lisp

The package exports six symbols: two query functions, an interactive REPL, a debug flag, and two configuration parameters:

1 ;;; package.lisp -- Package definition for cl-ai-coding-agent
2 (defpackage :cl-ai-coding-agent
3   (:use :cl)
4   (:export #:coding-agent-query
5            #:coding-agent-query-file
6            #:coding-agent-repl
7            #:*verbose*
8            #:*default-model*
9            #:*max-tool-rounds*))

File-System Tools

The agent has three tools, each consisting of a local implementation function and a corresponding litelm tool definition.

Tool Implementations

 1 ;;; tools.lisp -- File-system tools for cl-ai-coding-agent
 2 (in-package :cl-ai-coding-agent)
 3 
 4 ;;; ---- Helper functions executed locally ----
 5 
 6 (defun tool-list-directory (dir)
 7   "List files and subdirectories in DIR.
 8    Excludes hidden and backup entries.
 9    Returns a newline-separated string of names
10    relative to DIR."
11   ;; TRUENAME canonicalises the directory.  Without it a relative
12   ;; argument such as \".\" stays relative, and ENOUGH-NAMESTRING cannot
13   ;; strip an absolute entry name against a relative default -- so the
14   ;; model would receive full absolute paths instead of relative ones.
15   ;; It also turns a misspelled directory into an error rather than an
16   ;; empty listing.
17   (let* ((resolved (truename
18                     (uiop:ensure-directory-pathname (or dir "."))))
19          (entries
20           (append (uiop:directory-files resolved)
21                   (uiop:subdirectories resolved))))
22     (if entries
23         (with-output-to-string (out)
24           (dolist (e entries)
25             (let ((name (enough-namestring e resolved)))
26               (unless (or (uiop:string-prefix-p "." name)
27                           (uiop:string-suffix-p "~" name)
28                           (uiop:string-prefix-p "#" name))
29                 (format out "~A~%" name)))))
30         (format nil "(empty directory: ~A)" resolved))))
31 
32 (defun tool-read-file (path)
33   "Return the contents of PATH as a string.
34    Signals an error when the file does not exist."
35   (let ((truepath (probe-file path)))
36     (unless truepath
37       (error "File not found: ~A" path))
38     (uiop:read-file-string truepath)))
39 
40 (defun tool-write-file (path content)
41   "Write CONTENT to PATH, creating parent directories
42    as needed.  Returns a confirmation message."
43   (let ((pathname (pathname path)))
44     (ensure-directories-exist pathname)
45     (with-open-file (out pathname
46                         :direction :output
47                         :if-exists :supersede
48                         :if-does-not-exist :create)
49       (write-string content out))
50     (format nil "Wrote ~D characters to ~A"
51             (length content) path)))

Each tool returns a string. This is important because tool results travel back to the model as text messages, so even tool-list-directory and tool-write-file produce human-readable string output rather than structured data.

The tool-list-directory function filters out hidden files (names starting with .), Emacs backup files (ending with ~), and Emacs auto-save files (starting with #). The truename call canonicalises the directory first, and enough-namestring then strips that prefix so the output is clean relative names rather than full absolute paths. The truename is doing real work here: leave the directory as a relative pathname such as ./ and enough-namestring cannot relativise the absolute names that directory-files returns, so the model receives full paths after all. It also turns a misspelled directory into an error the model can see, rather than letting it look like an empty folder.

The tool-write-file function calls ensure-directories-exist before writing, so the model can create files in new subdirectories without a separate mkdir step.

litelm Tool Definitions

Each tool needs a corresponding definition that tells the model the function’s name, description, and parameters. The litelm library uses a plain Lisp format: each tool is a list of (name description ((param-name param-type param-description) ...)).

 1 ;;; ---- litelm tool definitions ----
 2 ;;;
 3 ;;; Tools use litelm's Lisp format:
 4 ;;;   (name description ((param type desc) ...))
 5 
 6 (defun %make-tool-declarations ()
 7   "Build the list of litelm tool definitions
 8    for file-system tools."
 9   '((list_directory
10      "List files and subdirectories in a directory.
11       Returns one entry per line."
12      ((path "string"
13        "Absolute or relative directory path")))
14     (read_file
15      "Read the full contents of a text file and
16       return it as a string."
17      ((path "string"
18        "Absolute or relative file path")))
19     (write_file
20      "Create or overwrite a file with the given
21       content.  Parent directories are created
22       automatically."
23      ((path "string"
24        "Absolute or relative file path")
25       (content "string"
26        "The full text content to write")))))

There is no JSON anywhere in our code: litelm translates these Lisp lists into the wire format expected by whichever provider the model string selects. Parameters are required by default, so the model knows it must supply both path and content when calling write_file.

Tool Dispatch

When the model requests a tool call, litelm returns a plist with :ID, :NAME, and :ARGUMENTS. The dispatch function routes each call to the correct local function:

 1 ;;; ---- Dispatch a tool-call plist ----
 2 
 3 (defun dispatch-tool-call (fc)
 4   "Execute the tool described by tool-call plist
 5    FC (:ID :NAME :ARGUMENTS) as returned by
 6    litelm:response-tool-calls.
 7    Returns a string result."
 8   (let* ((name (getf fc :name))
 9          (args (getf fc :arguments))
10          (get-arg (lambda (key)
11                     (let ((pair (assoc key args
12                                        :test #'string-equal)))
13                       ;; Report a missing argument by name.  Letting NIL
14                       ;; reach the tool would surface a raw SBCL type error
15                       ;; ("NIL is not of type ...") instead of something the
16                       ;; model can act on.
17                       (unless (and pair (cdr pair))
18                         (error "Missing required argument ~S for tool ~A"
19                                key name))
20                       (cdr pair)))))
21     (handler-case
22         (cond
23           ((string-equal name "list_directory")
24            (tool-list-directory
25             (funcall get-arg "path")))
26           ((string-equal name "read_file")
27            (tool-read-file
28             (funcall get-arg "path")))
29           ((string-equal name "write_file")
30            (tool-write-file
31             (funcall get-arg "path")
32             (funcall get-arg "content")))
33           (t (format nil "Unknown tool: ~A" name)))
34       (error (e)
35         (format nil "Tool error (~A): ~A" name e)))))

The :ARGUMENTS value is a decoded alist, for example ((:path . "/tmp/test.lisp")), so the get-arg lambda uses assoc with :test #'string-equal to handle case variations. The outer handler-case catches tool errors (like missing files) and returns them as strings rather than signaling conditions that would break the agent loop. This is crucial: if a file does not exist, we want the model to see the error message and either try a different approach or explain the problem to the user.

The Agent Core

Stacktrace Detection

Before constructing the system message, the agent checks whether the user’s input contains a stacktrace or error message. If it does, additional instructions are injected to guide the model toward root-cause analysis:

 1 ;;; agent.lisp -- Core agent logic for cl-ai-coding-agent
 2 (in-package :cl-ai-coding-agent)
 3 
 4 (defvar *verbose* nil
 5   "When non-NIL, print debug information during
 6    agent execution.")
 7 
 8 (defparameter *max-tool-rounds* 10
 9   "Maximum number of tool-use round-trips before
10    the agent returns whatever it has.")
11 
12 (defparameter *default-model* "ollama/qwen3.5:4b"
13   "Default litelm model for the agent.
14    Uses a local Ollama model, so no API key
15    is needed.")
16 
17 ;;; ---- Stacktrace detection ----
18 
19 (defparameter *stacktrace-patterns*
20   '("Backtrace"
21     "BACKTRACE"
22     "debugger invoked"
23     "Unhandled"
24     "HANDLER-BIND"
25     "is not of type"
26     "UNDEFINED-FUNCTION"
27     "SIMPLE-ERROR"
28     "PROGRAM-ERROR"
29     "TYPE-ERROR"
30     "UNBOUND-VARIABLE"
31     "SB-INT:SIMPLE-READER-ERROR"
32     "Traceback (most recent call last)"
33     "Exception in thread"
34     "Error:"
35     "Stack trace:")
36   "Literal substrings -- not regular expressions -- that
37    indicate the input contains a stacktrace or error
38    message.  STACKTRACE-P searches with SEARCH, so a pattern
39    written as a regex would silently never match.")
40 
41 (defun stacktrace-p (text)
42   "Return T if TEXT likely contains a stacktrace
43    or Common Lisp error output, and NIL otherwise."
44   ;; SOME returns the value SEARCH produced, which is a match
45   ;; position, so coerce it to a real boolean.
46   (and (some (lambda (pat)
47                (search pat text :test #'char-equal))
48              *stacktrace-patterns*)
49        t))

The patterns cover SBCL conditions (UNDEFINED-FUNCTION, TYPE-ERROR, debugger invoked), Python tracebacks (Traceback (most recent call last)), and Java or generic stack traces (Exception in thread, Stack trace:). The char-equal test makes the search case-insensitive. Every pattern is matched with search, so they are literal substrings rather than regular expressions — one written as a regex would simply never match, and would sit in the list looking useful. Detection is heuristic: it can fire on benign input containing the word Error:, but a false positive is harmless, since the extra instructions only add diagnostic guidance without changing the agent’s capabilities.

System Message Construction

The system message establishes the agent’s persona and informs it about available tools:

 1 ;;; ---- System prompt construction ----
 2 
 3 (defun %system-instructions (user-prompt)
 4   "Build the system message for USER-PROMPT."
 5   (let ((stacktrace-instructions
 6          (if (stacktrace-p user-prompt)
 7              "The user's input contains a stacktrace
 8 or error message.  Analyze it carefully:
 9 1. Identify the root cause of the error.
10 2. Explain what went wrong in plain English.
11 3. Suggest a concrete fix with corrected code.
12 Only use file-system tools if you genuinely need
13 to see source code context.  If the error message
14 is self-explanatory, answer directly without
15 reading files.
16 
17 "
18              "")))
19     (format nil
20 "You are an expert Common Lisp coding assistant.
21 You have access to three file-system tools:
22   - list_directory: list contents of a directory
23   - read_file: read a file's contents
24   - write_file: create or overwrite a file
25 
26 Use these tools when the user asks you to inspect
27 or modify files.  When creating new files, always
28 use write_file, do not just print the code.
29 
30 ~A" stacktrace-instructions)))

Note that %system-instructions builds only the system message. The user’s input travels as a separate :user entry in the message list, which matches the chat message format that litelm expects.

The system message explicitly instructs the model to use write_file when creating files rather than just printing code. Without this instruction, models tend to respond with code blocks in their text output, which is useful for a chatbot but unhelpful when you want the agent to actually create the file on disk.

The stacktrace instructions include a restraint: “Only use file-system tools if you genuinely need to see source code context.” This prevents the model from reflexively reading every file in the project when the error message alone provides enough information for a diagnosis.

The Agent Loop

The coding-agent-query function is the primary entry point. It maintains one message list and calls litelm:completion once per round:

 1 ;;; ---- Agent loop ----
 2 
 3 (defun coding-agent-query (prompt &key (model *default-model*))
 4   "Process PROMPT through the AI coding agent.
 5    The agent can read directories, read files,
 6    write new files, and diagnose stacktraces.
 7    MODEL is a litelm \"provider/model\" string,
 8    defaulting to *DEFAULT-MODEL*.
 9    Returns the final text response."
10   (let* ((tools (%make-tool-declarations))
11          (messages
12           (list (list :system
13                       (%system-instructions prompt))
14                 (list :user prompt)))
15          (round 0)
16          text calls)
17     (when *verbose*
18       (format t "~&[coding-agent] model: ~A~%~A~%"
19               model messages))
20     (loop
21       (incf round)
22       (let ((resp (litelm:completion model
23                                      :messages messages
24                                      :tools tools)))
25         (setf text (litelm:response-content resp)
26               calls (litelm:response-tool-calls resp))
27         (when *verbose*
28           (format t "[coding-agent] round-~D text: ~A~%"
29                   round text)
30           (format t "[coding-agent] round-~D calls: ~A~%"
31                   round calls))
32         (unless calls
33           (return (or text "(no response from model)")))
34         (when (> round *max-tool-rounds*)
35           (return (or text "(agent exhausted tool rounds)")))
36         (setf messages
37               (nconc messages
38                      (list (list :assistant text
39                                  :tool-calls calls))
40                      (mapcar (lambda (fc)
41                                (let ((result
42                                       (dispatch-tool-call fc)))
43                                  (when *verbose*
44                                    (format t
45                                     "[coding-agent] tool ~A -> ~A~%"
46                                     (getf fc :name)
47                                     (subseq result 0
48                                      (min 200
49                                       (length result)))))
50                                  (list :tool result
51                                        :tool-call-id
52                                        (getf fc :id))))
53                              calls)))))))

The key design decisions here:

  • One growing message list: Each round appends an :assistant message carrying the model’s :tool-calls, followed by one :tool message per call carrying the result string and the :tool-call-id. Because litelm owns the wire translation, this same history works with every provider.
  • Early return: If a round produces text with no tool calls, the agent returns immediately. Most simple questions (“What does defmethod do?”) are answered in a single round.
  • Mapcar over calls: All tool calls from a single round are executed before the next completion request. Models sometimes request multiple calls in one round (for example, reading two files at once), and processing them all at once is more efficient than sequential round-trips.
  • Round limit: The *max-tool-rounds* parameter (default 10) prevents runaway loops. In practice, most interactions complete in 1 to 3 rounds: list directory, read file, respond.
  • Model selection: The :model keyword accepts any litelm "provider/model" string, so switching from the local default to gemini/gemini-2.5-flash or openai/gpt-4o is a one-word change.
  • Verbose mode: Setting *verbose* to t prints every message list, tool call, and response. The debug output truncates tool results to 200 characters to keep the output manageable.

File-Based Queries

Stacktraces and error messages are often multi-line and contain double-quote characters, making them painful to paste into a Lisp string literal. The coding-agent-query-file function solves this by reading the prompt from a file:

 1 ;;; ---- File-based query ----
 2 
 3 (defun coding-agent-query-file (path
 4                                 &optional prefix
 5                                 (model *default-model*))
 6   "Read the contents of PATH and send them as a
 7    prompt to the coding agent.  This is the easiest
 8    way to diagnose multi-line stacktraces that may
 9    contain quote characters -- just save the error
10    output to a file and pass the path here.
11    PREFIX is an optional string prepended to the
12    file contents (e.g. \"Fix this error:\").
13    MODEL is a litelm \"provider/model\" string."
14   (let* ((content (uiop:read-file-string path))
15          (prompt (if prefix
16                      (format nil "~A~%~A"
17                              prefix content)
18                      content)))
19     (coding-agent-query prompt :model model)))

The typical workflow: copy a stacktrace to the clipboard, save it to a file (pbpaste > /tmp/error.txt on macOS), and call (coding-agent-query-file "/tmp/error.txt").

Interactive REPL

For extended sessions, the agent provides its own REPL loop:

 1 ;;; ---- Interactive REPL ----
 2 
 3 (defparameter *whitespace* '(#\Space #\Tab #\Newline #\Return)
 4   "Characters trimmed from REPL input.")
 5 
 6 (defun coding-agent-repl ()
 7   "Start an interactive REPL for the coding agent.
 8    Type 'quit' or 'exit' to leave."
 9   (format t "~&AI Coding Agent (type quit to exit)~%")
10   (loop
11     (format t "~&> ")
12     (finish-output)
13     (let ((input (read-line *standard-input* nil nil)))
14       (when (null input)
15         (format t "~&Goodbye.~%")
16         (return))
17       (let ((trimmed (string-trim *whitespace* input)))
18         (cond
19           ((member trimmed '("quit" "exit")
20                    :test #'string-equal)
21            (format t "~&Goodbye.~%")
22            (return))
23           ((plusp (length trimmed))
24            (let ((response
25                   (handler-case
26                       (coding-agent-query trimmed)
27                     (error (e)
28                       (format nil "Error: ~A" e)))))
29              (format t "~&~A~%" response))))))))

The handler-case around coding-agent-query ensures that API errors, network timeouts, and other failures produce a message rather than dropping into the SBCL debugger. This matters for a tool you use throughout the day, since you do not want to lose your REPL state because of a transient network issue.

Installation

The agent depends on the litelm library, which lives in the sibling directory ../litelm. Load both system definitions and quickload the agent:

1 (asdf:load-asd "/path/to/litelm/litelm.asd")
2 (asdf:load-asd "/path/to/cl-ai-coding-agent/cl-ai-coding-agent.asd")
3 (ql:quickload :cl-ai-coding-agent)

Replace /path/to with the checkout’s src directory — src/litelm/litelm.asd and src/cl-ai-coding-agent/cl-ai-coding-agent.asd in this repository.

The default model runs locally under Ollama, so no API key is needed. Pull the model once from your terminal:

1 ollama pull qwen3.5:4b

To use a hosted provider instead, pass a different :model string and set the corresponding API key environment variable (for example GEMINI_API_KEY for gemini/ models), as described in the litelm chapter.

Usage Examples

One-Shot Queries

 1 ;; Ask about files in the current directory
 2 (cl-ai-coding-agent:coding-agent-query
 3   "What files are in the current directory?")
 4 
 5 ;; Generate and write a new file
 6 (cl-ai-coding-agent:coding-agent-query
 7   "Write a file hello.lisp with a hello-world function.")
 8 
 9 ;; Diagnose an error
10 (cl-ai-coding-agent:coding-agent-query
11   "debugger invoked on a UNDEFINED-FUNCTION:
12    The function FOO is undefined.")
13 
14 ;; Use a hosted model instead of the local default
15 (cl-ai-coding-agent:coding-agent-query
16   "What files are here?"
17   :model "gemini/gemini-2.5-flash")

Diagnosing Stacktraces from Files

 1 ;; Save a stacktrace to a file:
 2 ;;   pbpaste > /tmp/error.txt
 3 ;;
 4 ;; Then in the REPL:
 5 (cl-ai-coding-agent:coding-agent-query-file
 6   "/tmp/error.txt")
 7 
 8 ;; With a context prefix:
 9 (cl-ai-coding-agent:coding-agent-query-file
10   "/tmp/error.txt"
11   "Fix this error in my project:")

Debug Mode

1 (setf cl-ai-coding-agent:*verbose* t)
2 (cl-ai-coding-agent:coding-agent-query
3   "List the Lisp files in src/")
4 ;; Prints model, tool calls, responses, and round info

Interactive REPL

1 (cl-ai-coding-agent:coding-agent-repl)
2 ;; AI Coding Agent (type quit to exit)
3 ;; > What files are here?
4 ;; ...
5 ;; > Write a test file for the cache-engine
6 ;; ...
7 ;; > quit

Example Session

The following session demonstrates the agent’s multi-round tool use. The user asks the agent to describe the Lisp files in a directory, and the agent lists the directory, reads each file, then summarizes them:

 1 * (cl-ai-coding-agent:coding-agent-query
 2     "List the .lisp files in this directory
 3      and describe what each one does")
 4 
 5 The current directory contains three Lisp source files:
 6 
 7 1. **package.lisp** - Defines the `cl-ai-coding-agent`
 8    package and exports six symbols: `coding-agent-query`,
 9    `coding-agent-query-file`, `coding-agent-repl`,
10    `*verbose*`, `*default-model*`, and `*max-tool-rounds*`.
11 
12 2. **tools.lisp** - Implements three file-system tools
13    (`list_directory`, `read_file`, `write_file`) that
14    the agent can use autonomously, plus the litelm tool
15    definitions that describe these tools to the model.
16 
17 3. **agent.lisp** - Contains the core agent loop, system
18    message construction, stacktrace detection, and the
19    public API functions.

Behind the scenes, this query triggered three tool-use rounds:

  1. Round 1: The model called list_directory with path "." and received the file listing.
  2. Round 2: The model called read_file three times (once per .lisp file) to read their contents.
  3. Round 3: The model produced the final text summary.

Key Takeaways

  1. Native tool calling over JSON parsing: litelm implements the OpenAI-compatible tool-calling protocol shared by many providers. By declaring tools as plain Lisp lists and dispatching the model’s typed plist requests directly, we avoid the brittleness of parsing free-form JSON from model output.

  2. Provider independence: Because the agent loop only calls litelm:completion, the entire library works unchanged against OpenAI, Gemini, Fireworks AI, DeepSeek, or a local Ollama server. The default ollama/qwen3.5:4b model needs no API key.

  3. Returning errors as strings: Wrapping tool dispatch in handler-case and returning error messages as strings (rather than signaling conditions) keeps the agent loop running. The model can see the error and adapt, for example by trying a different file path.

  4. Stacktrace-aware prompting: Detecting error patterns in the input and injecting diagnostic instructions produces better root-cause analysis. The restraint instruction (“only use tools if you genuinely need context”) prevents unnecessary file reads.

  5. File-based input for awkward text: The coding-agent-query-file function sidesteps the quoting problem inherent in pasting multi-line, quote-heavy stacktraces into Lisp string literals.

  6. Composition with the REPL: This library is designed to be loaded into ~/.sbclrc and used alongside normal Lisp development. The Hacking the SBCL REPL chapter shows how to integrate it with #? reader macros and automatic error interception for a seamless coding experience.

Wrap Up

You can use this example in building your own coding environment. In the chapter Hacking the SBCL REPL we’ll see how to use this agent in an interactive SBCL REPL.

Optional Practice Problems

  1. Add a grep_files Tool: The agent can list directories and read files, but it has no way to search for text across multiple files. Add a fourth tool grep_files that accepts a pattern string and a directory string, uses uiop:run-program to call the system grep command (e.g., grep -rnl pattern directory), and returns matching filenames and line numbers as a string. Write the corresponding litelm tool definition in %make-tool-declarations, add a dispatch clause in dispatch-tool-call, and test it by asking the agent “Find all files that reference defmethod in this directory.”

  2. Extend Stacktrace Detection: The current stacktrace-p function uses simple substring matching via search. Extend it to also recognize Clojure stacktraces (patterns like clojure.lang.ExceptionInfo, at clojure.core), Rust panics (thread 'main' panicked at), and Go panics (goroutine 1 [running]). Then write a function classify-error-type that returns a keyword (:sbcl, :python, :java, :clojure, :rust, :go, or :unknown) based on which patterns match, and modify %system-instructions to inject language-specific diagnostic instructions depending on the classification.

  3. Conversation Logger: The agent currently discards all intermediate tool calls and responses after the session ends. Write a logging layer that wraps coding-agent-query: before each query, open (or create) a log file agent-log.jsonl and append a JSON object for each event: the initial prompt, each tool call (name and arguments), each tool response, and the final answer. Use litelm:json-encode to serialize each entry. This provides an audit trail and lets you replay sessions later.

  4. Safety Sandbox: The tool-write-file function can write to any path, including system files. Write a wrapper function sandboxed-write-file that accepts a list of allowed directory prefixes (e.g., ("/tmp/" "/home/user/projects/")) and checks that the resolved absolute path of the target file starts with one of the allowed prefixes before writing. If the path is outside the sandbox, return an error string instead of writing. Integrate this into dispatch-tool-call so that write operations are always sandboxed. Discuss why probe-file alone is insufficient for this check (hint: symlinks).

  5. Batch File Reviewer: Write a function review-project that takes a directory path, uses tool-list-directory to discover all .lisp files, then calls coding-agent-query once with a prompt that includes the contents of every file (read via tool-read-file) and asks the agent to review the code for style issues, potential bugs, and missing documentation. Be mindful of context window limits: estimate the prompt size before sending (a rough heuristic is four characters per token), and if it exceeds a threshold (e.g., 100,000 estimated tokens), split the files into batches and make multiple queries.

  6. Token-Aware Agent Loop: The current agent loop has a round limit (*max-tool-rounds*) but no token budget. Modify coding-agent-query to accept an optional :max-tokens keyword parameter. After each call to litelm:completion, inspect litelm:response-usage, which returns a plist (:prompt-tokens n :completion-tokens n :total-tokens n), and check the :total-tokens value against :max-tokens. If the budget is exceeded, return early with a note that the token budget ran out, or truncate the oldest tool responses from the message history and continue. This teaches you about managing context windows in multi-round LLM interactions.