Building an AI Coding Assistant for Common Lisp
In the previous chapter on Agents Orchestrating LLM Tool Use we built a general-purpose multi-agent framework with a tool registry, context objects, and a JSON-based action protocol. That framework is flexible, but its generality comes at a cost: the model must produce well-formed JSON action lists, the tool registry uses string-keyed hash tables, and the conversation loop parses free-form LLM output that may or may not contain markdown fences. For an agent whose only job is to help you write and debug code from within the SBCL REPL, we can do better.
In this chapter we build cl-ai-coding-agent, a focused coding assistant that uses Gemini’s native function-calling API — the Interactions API — instead of asking the model to emit JSON tool invocations. The model declares which functions it wants to call using a structured protocol, the Interactions API returns typed function-call objects, and our agent loop dispatches them directly. This eliminates the fragile JSON parsing layer entirely and lets Gemini decide autonomously when to inspect directories, read source files, write new files, or simply answer a question.
The result is a small library — three source files totaling roughly 300 lines — that you can load into any SBCL session and use immediately. Later in the Hacking the SBCL REPL chapter we integrate it further with reader macros (#?) and automatic error interception so the agent is always one keystroke away.
Architecture
The agent follows a simple multi-turn loop built on the Gemini Interactions API:
- Prompt augmentation — The user’s input is wrapped with a system prompt that describes the available tools. If the input looks like a stacktrace, additional diagnostic instructions are injected.
- Turn 1 — The augmented prompt and tool declarations are sent to Gemini via
generate-with-tools. Gemini returns either a text response (no tools needed) or a list of function-call requests. - Tool dispatch — Each function call is executed locally. The agent can list directories, read files, and write files.
- Turn 2+ — Tool results are sent back via
continue-with-function-responses. Gemini may request more tool calls (e.g., read a file after listing a directory) or return a final text response. - Termination — The loop repeats for up to 10 rounds, then returns whatever text the model has produced.
This is fundamentally different from the cl-llm-agent framework in the previous chapter. There, the model had to produce a JSON {"actions": [...]} response that our code parsed and dispatched. Here, the Interactions API handles the structured protocol — we never parse JSON tool invocations ourselves. The model’s function-call requests arrive as typed plists, and our tool results are sent back through the same API. 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 Gemini function declarations |
agent.lisp |
System prompt construction, agent loop, and public API |
cl-ai-coding-agent.asd |
ASDF system definition |
The only external dependency beyond SBCL itself is the gemini library (developed in the Gemini chapter) and UIOP (which ships with ASDF).
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 :name "cl-ai-coding-agent"
6 :version "0.1.0"
7 :author "Mark Watson <markw@markwatson.com>"
8 :license "Apache 2"
9 :description
10 "An AI coding agent that reads directories and files,
11 writes new files, and diagnoses stacktraces."
12 :depends-on ("gemini" "uiop")
13 :components ((:file "package")
14 (:file "tools")
15 (:file "agent")))
package.lisp
The package exports four symbols — two query functions, an interactive REPL, and a debug flag:
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*))
File-System Tools
The agent has three tools, each consisting of a local implementation function and a corresponding Gemini function declaration.
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 pathnames."
10 (let* ((resolved (uiop:ensure-directory-pathname
11 (or dir ".")))
12 (entries
13 (append (uiop:directory-files resolved)
14 (uiop:subdirectories resolved))))
15 (if entries
16 (with-output-to-string (out)
17 (dolist (e entries)
18 (let ((name (enough-namestring e resolved)))
19 (unless (or (uiop:string-prefix-p "." name)
20 (uiop:string-suffix-p "~" name)
21 (uiop:string-prefix-p "#" name))
22 (format out "~A~%" name)))))
23 (format nil "(empty directory: ~A)" resolved))))
24
25 (defun tool-read-file (path)
26 "Return the contents of PATH as a string.
27 Signals an error when the file does not exist."
28 (let ((truepath (probe-file path)))
29 (unless truepath
30 (error "File not found: ~A" path))
31 (uiop:read-file-string truepath)))
32
33 (defun tool-write-file (path content)
34 "Write CONTENT to PATH, creating parent directories
35 as needed. Returns a confirmation message."
36 (let ((pathname (pathname path)))
37 (ensure-directories-exist pathname)
38 (with-open-file (out pathname
39 :direction :output
40 :if-exists :supersede
41 :if-does-not-exist :create)
42 (write-string content out))
43 (format nil "Wrote ~D characters to ~A"
44 (length content) path)))
Each tool returns a string. This is important — the Gemini Interactions API expects function results to be text, 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 enough-namestring call strips the directory prefix so the output is clean relative names rather than full absolute paths.
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.
Gemini Function Declarations
Each tool needs a corresponding declaration that tells Gemini the function’s name, description, parameter types, and which parameters are required:
1 ;;; ---- Gemini function declarations ----
2
3 (defun %make-tool-declarations ()
4 "Build the list of Gemini function declarations
5 for file-system tools."
6 (list
7 (gemini:make-function-declaration
8 "list_directory"
9 "List files and subdirectories in a directory.
10 Returns one entry per line."
11 '(("path" "STRING"
12 "Absolute or relative directory path"))
13 '("path"))
14
15 (gemini:make-function-declaration
16 "read_file"
17 "Read the full contents of a text file and
18 return it as a string."
19 '(("path" "STRING"
20 "Absolute or relative file path"))
21 '("path"))
22
23 (gemini:make-function-declaration
24 "write_file"
25 "Create or overwrite a file with the given
26 content. Parent directories are created
27 automatically."
28 '(("path" "STRING"
29 "Absolute or relative file path")
30 ("content" "STRING"
31 "The full text content to write"))
32 '("path" "content"))))
Each declaration is a hash-table built by gemini:make-function-declaration. The third argument is a list of (name type description) triples defining the parameters, and the optional fourth argument lists which parameters are required. These declarations are sent to Gemini alongside the user’s prompt so the model knows exactly what tools are available and how to call them.
Tool Dispatch
When Gemini requests a function call, it returns a plist with :NAME, :ID, and :ARGS. The dispatch function routes each call to the correct local function:
1 ;;; ---- Dispatch a function-call plist ----
2
3 (defun dispatch-tool-call (fc)
4 "Execute the tool described by function-call
5 plist FC (:NAME :ID :ARGS).
6 Returns a string result."
7 (let* ((name (getf fc :name))
8 (args (getf fc :args))
9 (get-arg (lambda (key)
10 (cdr (assoc key args
11 :test #'string-equal)))))
12 (handler-case
13 (cond
14 ((string-equal name "list_directory")
15 (tool-list-directory
16 (funcall get-arg "path")))
17 ((string-equal name "read_file")
18 (tool-read-file
19 (funcall get-arg "path")))
20 ((string-equal name "write_file")
21 (tool-write-file
22 (funcall get-arg "path")
23 (funcall get-arg "content")))
24 (t (format nil "Unknown tool: ~A" name)))
25 (error (e)
26 (format nil "Tool error (~A): ~A" name e)))))
The :ARGS value is a cl-json-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 doesn’t exist, we want Gemini 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 prompt, 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 ;;; ---- Stacktrace detection ----
13
14 (defparameter *stacktrace-patterns*
15 '("Backtrace"
16 "BACKTRACE"
17 "debugger invoked"
18 "Unhandled"
19 "HANDLER-BIND"
20 "The value"
21 "is not of type"
22 "UNDEFINED-FUNCTION"
23 "SIMPLE-ERROR"
24 "PROGRAM-ERROR"
25 "TYPE-ERROR"
26 "UNBOUND-VARIABLE"
27 "SB-INT:SIMPLE-READER-ERROR"
28 "Traceback (most recent call last)"
29 "at .* line [0-9]+"
30 "Exception in thread"
31 "Error:"
32 "Stack trace:")
33 "Patterns indicating the input contains a
34 stacktrace or error message.")
35
36 (defun stacktrace-p (text)
37 "Return T if TEXT likely contains a stacktrace
38 or Common Lisp error output."
39 (some (lambda (pat)
40 (search pat text :test #'char-equal))
41 *stacktrace-patterns*))
The patterns cover SBCL conditions (UNDEFINED-FUNCTION, TYPE-ERROR, debugger invoked), Python tracebacks (Traceback (most recent call last)), and Java/generic stack traces (Exception in thread, Stack trace:). The char-equal test makes the search case-insensitive. This detection is heuristic — it may occasionally fire on benign input containing the word “Error:” — but false positives are harmless since the extra instructions only add diagnostic guidance without changing the agent’s capabilities.
System Prompt Construction
The system prompt establishes the agent’s persona and informs it about available tools:
1 ;;; ---- System prompt construction ----
2
3 (defun %system-prompt (user-prompt)
4 "Build the full prompt sent to Gemini, including
5 the system instructions and the user's input."
6 (let ((stacktrace-instructions
7 (if (stacktrace-p user-prompt)
8 "The user's input contains a stacktrace
9 or error message. Analyze it carefully:
10 1. Identify the root cause of the error.
11 2. Explain what went wrong in plain English.
12 3. Suggest a concrete fix with corrected code.
13 Only use file-system tools if you genuinely need
14 to see source code context. If the error message
15 is self-explanatory, answer directly without
16 reading files.
17
18 "
19 "")))
20 (format nil
21 "You are an expert Common Lisp coding assistant.
22 You have access to three file-system tools:
23 - list_directory: list contents of a directory
24 - read_file: read a file's contents
25 - write_file: create or overwrite a file
26
27 Use these tools when the user asks you to inspect
28 or modify files. When creating new files, always
29 use write_file — do NOT just print the code.
30
31 ~AUser request:
32 ~A" stacktrace-instructions user-prompt)))
The system prompt 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 — 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 orchestrates the multi-turn conversation with Gemini:
1 ;;; ---- Agent loop ----
2
3 (defun coding-agent-query (prompt)
4 "Process PROMPT through the AI coding agent.
5 The agent can read directories, read files,
6 write new files, and diagnose stacktraces.
7 Returns the final text response."
8 (let* ((declarations (%make-tool-declarations))
9 (full-prompt (%system-prompt prompt)))
10 (when *verbose*
11 (format t "~&[coding-agent] prompt:~%~A~%"
12 full-prompt))
13 ;; Turn 1 -- send prompt with tools
14 (multiple-value-bind (text calls interaction-id)
15 (gemini:generate-with-tools
16 full-prompt declarations)
17 (when *verbose*
18 (format t "[coding-agent] turn-1 text: ~A~%"
19 text)
20 (format t "[coding-agent] turn-1 calls: ~A~%"
21 calls))
22 ;; If no tool calls, return the text directly
23 (unless calls
24 (return-from coding-agent-query
25 (or text "(no response from model)")))
26 ;; Multi-turn tool loop
27 (loop for round from 1 to *max-tool-rounds*
28 while calls
29 do
30 (let ((responses
31 (mapcar
32 (lambda (fc)
33 (let ((result
34 (dispatch-tool-call fc)))
35 (when *verbose*
36 (format t
37 "[coding-agent] tool ~A -> ~A~%"
38 (getf fc :name)
39 (subseq result 0
40 (min 200
41 (length result)))))
42 (list :name (getf fc :name)
43 :id (getf fc :id)
44 :response result)))
45 calls)))
46 (multiple-value-bind
47 (next-text next-calls next-iid)
48 (gemini:continue-with-function-responses
49 interaction-id
50 responses
51 declarations)
52 (when *verbose*
53 (format t
54 "[coding-agent] round-~D text: ~A~%"
55 round next-text)
56 (format t
57 "[coding-agent] round-~D calls: ~A~%"
58 round next-calls))
59 (setf text next-text
60 calls next-calls
61 interaction-id next-iid)))
62 finally
63 (return
64 (or text
65 "(agent exhausted tool rounds)"))))))
The key design decisions here:
- Early return — If Turn 1 produces text with no function calls, the agent returns immediately. Most simple questions (“What does
defmethoddo?”) are answered in a single turn. - Mapcar over calls — All function calls from a single turn are executed before sending results back. Gemini sometimes requests multiple calls in one turn (e.g., listing two directories simultaneously), 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–3 rounds: list directory → read file → respond. - Verbose mode — Setting
*verbose*totprints every prompt, 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 "Read the contents of PATH and send them as a
6 prompt to the coding agent. This is the easiest
7 way to diagnose multi-line stacktraces that may
8 contain quote characters -- just save the error
9 output to a file and pass the path here.
10 PREFIX is an optional string prepended to the
11 file contents (e.g. \"Fix this error:\")."
12 (let* ((content (uiop:read-file-string path))
13 (prompt (if prefix
14 (format nil "~A~%~A"
15 prefix content)
16 content)))
17 (coding-agent-query prompt)))
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 (defun coding-agent-repl ()
4 "Start an interactive REPL for the coding agent.
5 Type 'quit' or 'exit' to leave."
6 (format t "~&AI Coding Agent (type quit to exit)~%")
7 (loop
8 (format t "~&> ")
9 (finish-output)
10 (let ((input (read-line *standard-input*
11 nil nil)))
12 (when (or (null input)
13 (string-equal (string-trim
14 '(#\Space) input)
15 "quit")
16 (string-equal (string-trim
17 '(#\Space) input)
18 "exit"))
19 (format t "~&Goodbye.~%")
20 (return))
21 (let ((trimmed (string-trim '(#\Space) input)))
22 (when (plusp (length trimmed))
23 (let ((response
24 (handler-case
25 (coding-agent-query trimmed)
26 (error (e)
27 (format nil "Error: ~A" e)))))
28 (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 — you don’t want to lose your REPL state because of a transient network issue.
Installation
1 (ql:quickload :cl-ai-coding-agent)
You need a GOOGLE_API_KEY environment variable set for the Gemini API.
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.")
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 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-turn tool use. The user asks the agent to describe the Lisp files in a directory — 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 four symbols: `coding-agent-query`,
9 `coding-agent-query-file`, `coding-agent-repl`, and
10 `*verbose*`.
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 Gemini
15 function declarations that describe these tools to
16 the model.
17
18 3. **agent.lisp** — Contains the core agent loop, system
19 prompt construction, stacktrace detection, and the
20 public API functions.
Behind the scenes, this query triggered three tool-use rounds:
- Round 1: Gemini called
list_directorywith path"."and received the file listing. - Round 2: Gemini called
read_filethree times (once per.lispfile) to read their contents. - Round 3: Gemini produced the final text summary.
Comparison with cl-llm-agent
The cl-ai-coding-agent and the cl-llm-agent framework from the previous chapter solve the same problem — giving an LLM access to external tools — but they make fundamentally different engineering choices:
| Aspect | cl-llm-agent | cl-ai-coding-agent |
|---|---|---|
| Tool protocol | LLM generates JSON; client parses it | Gemini Interactions API; structured function calls |
| Error handling | JSON parse failures → crashes | API-level; tools return error strings |
| Tool registry | Dynamic hash-table with register-tool
|
Static; three built-in tools |
| Multi-step |
PREV_RESULT placeholder in JSON |
Native multi-turn via interaction-id
|
| Dependencies | cl-json, gemini, tavily, fiveam | gemini, uiop |
| Scope | General-purpose framework | Focused coding assistant |
The cl-llm-agent framework is more extensible — you can register arbitrary tools at runtime, compose agents, and use different LLM backends. The cl-ai-coding-agent is more reliable for its specific use case because it eliminates the JSON parsing layer entirely. Neither approach is universally better; they serve different design goals.
Key Takeaways
Native function calling over JSON parsing — The Gemini Interactions API provides a structured protocol for tool use. By declaring functions with
make-function-declarationand dispatching the model’s typed requests directly, we avoid the brittleness of parsing free-form JSON from model output.Returning errors as strings — Wrapping tool dispatch in
handler-caseand returning error messages as strings (rather than signaling conditions) keeps the agent loop running. The model can see the error and adapt — for example, trying a different file path.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.
File-based input for awkward text — The
coding-agent-query-filefunction sidesteps the quoting problem inherent in pasting multi-line, quote-heavy stacktraces into Lisp string literals.Composition with the REPL — This library is designed to be loaded into
~/.sbclrcand 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 for cl-llm-agent
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.