If we look at the code for the agent right now, the chat loop is messy. It looks like
Get the user input -> Invoke the LLM
The code for the main loop looks like right now.
(loop [user-message (read-user-input!)
messages [(get-system-prompt)]]
(when (some? user-message)
(let [new-messages (add-message-to-history messages user-message)
{:keys [history usage]} (get-assistant-response new-messages config mcp-tools combined-registry)
assistant-message (:content (last history))]
(display-assistant-response! assistant-message)
(dbg-print usage)
(recur (read-user-input!) history))))
There is no way currently to do other things besides quit. That is also possible because we had added a hard-coded check inside the read-user-input! function to see if the user entered a quit message. When the user enters quit we return an empty input the same as if the user enter a EOF via Ctrl+D. This terminates the chat loop which is checking for the presence of some input from the user.
If you look at other agents available they provide /commands which allow the user to modify things like the conversation history, change models and so on. However, with our current read-user-input! method none of that is possible.
Let us refactor the method to make it extensible easily. We will achieve this by converting the simple loop into a state transition loop. We will add a new function handle-user-input! which will process special commands and indicate a state transition via a next state return value. Also, we want to be able to achieve things like clearing conversation history and changing models, so the handle-user-input! function needs to be able to change the state. To achieve this we will create a state map which is consists of the following:
{
:history [] ; A vector which holds the conversation history
:prompts {} ; A map which holds default prompts like the system prompt
:config {} ; A map containing the model information
:tools [] ; A vector of tools available to the model. Either MCP or coded tools
:tool-registry {} ; A map of tool name to the invocation function. This will allow us to handle tool calls from the model
:next-state :key ; The next state which the LLM chat loop should transition to
}
In this version we will support three states:
With these three states available our chat loop becomes simpler. The handle-user-input! function takes in the current state and returns a new state. This makes it easy for us to implement commands like clearing history, changing the model etc. As we can change the configuration which is stored inside the state map. After the implementation of our handle-user-input! function the main loop looks like:
(loop [{:keys [next-state] :as current-state} (state/handle-user-input! initial-state (read-user-input!))]
(cond
(= next-state :quit)
(do
(println "Exiting")
(doseq [server servers]
(println "Closing " (:name server))
(mcpclient/close-client (:client server))))
(= next-state :llm)
(let [{:keys [history] :as response} (get-assistant-response current-state)]
(display-assistant-response! response)
(recur (state/handle-user-input! (assoc current-state :history history) (read-user-input!))))
(= next-state :user)
(recur (state/handle-user-input! current-state (read-user-input!))))
Our handle-user-input! function can be written as:
(defn handle-user-input!
[{:keys [history prompts] :as state} input]
(if
(str/starts-with? input "/")
(let [args (str/split input #" ")
command (-> (first args) (subs 1) str/lower-case keyword)]
(cond (= command :quit)
(assoc state :next-state :quit)
(= command :clear)
(do
(println "Clearing history")
(assoc state :next-state :user
:history [(:system-prompt prompts)]))
(= command :debug)
(do
(println "====== Current State ======")
(pprint/pprint state)
(println "===========================")
(assoc state :next-state :user))
(= command :model)
(let [model-name (second args)
config (utils/read-config! (str "llm-" model-name ".edn"))]
(if (some? config)
(do
(println "Switching model to: " model-name)
(assoc state :config config :next-state :user))
state))
:else
(assoc state :next-state :user)))
(assoc state :next-state :llm
:history (add-message-to-history history {:role "user" :content input}))))
With this function it is trivial to add new commands. I have added commands for quitting, clearing history, generating debug output and switching models. This is much better than the old loop which could only handle quit commands. This will set us up for adding more commands like saving and loading conversations as well. I think this kind of clean state pattern is easily achievable in Clojure which forces immutability on the programmer. If I had used a different programming language which allowed mutation easily, I would have state changes all over the code. This also makes it very easy to test this code as well as the inputs and outputs are predictable.
The full listing of the code is here
Published: 2025-11-19
Now that we built a decently working coding agent, let us see if we can make it run against a local model. There are a lot of claims on the internet about local models being the future of this space. Let us validate if that claim holds true.
We will use Ollama to run a local model. Luckily for us it supports the OpenAI API out of the box. So, if you connect to http://localhost:11434/v1, you can reuse the agent that we already wrote to try out the different models.
My setup is a AMD Ryzen 1600 desktop with 32GB RAM and a RTX 3060 12 GB. So, I am both CPU and GPU poor :D.
The first one I tried is Llama 3.2 (3b). We have to look for models which support tool calling for the agent to work. For example, the Gemma models from Google do not support tool calling and so cannot be used with our agent. The model download was relatively small for Llama 3.2 - around 2 GB. It was decently fast also due to it fitting in the VRAM of my device completely. I could get responses within 2 seconds. However, that was where the positives ended. The model hallucinates tool calls and calls them incorrectly - mostly it is unable to generate the correct tool call format. I gave it a task of writing a Javascript FizzBuzz program and this is what I got.
You : Write me a FizzBuzz Javascript program with the tools available to you
Tool : run_shell_command
{:command
"node -e <garbage generated>}
LLM : It seems like the shell command is not executed correctly.
Here's a simplified version of FizzBuzz written in JavaScript using a conventional syntax:
function fizzBuzz(n) {
let output = '';
for (let i = 1; i <= n; i++) {
if (i % 3 === 0 && i % 5 === 0) {
output += 'FizzBuzz ';
} else if (i % 3 === 0) {
output += 'Fizz ';
} else if (i % 5 === 0) {
output += 'Buzz ';
} else {
output += i + ' ';
}
}
console.log(output);
}
fizzBuzz(20);
This function prints out the numbers from 1 to n with a twist: multiples of three are replaced by "Fizz", multiples of five by "Buzz", and multiples of both by "FizzBuzz".
To call this function, you can run it in your environment:
javascript
function fizzBuzz(n) {
let output = '';
for (let i = 1; i <= n; i++) {
if (i % 3 === 0 && i % 5 === 0) {
output += 'FizzBuzz ';
} else if (i % 3 === 0) {
output += 'Fizz ';
} else if (i % 5 === 0) {
output += 'Buzz ';
} else {
output += i + ' ';
}
}
console.log(output);
}
fizzBuzz(20);
Debug : {:completion_tokens 334, :prompt_tokens 227, :total_tokens 561} Response took - 3.772 s
You : quit
So, this model is not performing well. It could not even call the tool to read/write files correctly.
I had heard a lot of great things about the Qwen coding models. So, I tried the smallest of those next - the one with 1.5B parameters. Ollama downloads around 1GB of model data for this to run. I asked it what the program in the current folder does?
You : What does the program in the current folder do?
LLM : The program in the current folder is a Python script named <script-name>, and it uses the run_shell_command function from the tools section to execute the command find . -type f -exec cat {} + > results.txt.
json
{
"name": "run_shell_command",
"arguments": {
"command": "find ./ -type f -exec cat {} + > results.txt"
}
}
Debug : {:completion_tokens 91, :prompt_tokens 2405, :total_tokens 2496} Response took - 3.842 s
You : quit
No dice. It is also not able to run any tools. Maybe I should try with a larger model.
Maybe the size of the model is an issue. Let us try with a larger model. The latest Qwen 3 VL (8b) parameters. This one manages tool calls perfectly and is able to answer what the code in the current project does.
You : What does the program in the current folder do?
Tool : list_directory
{:path ". "}
Tool : list_allowed_directories
{}
Tool : list_directory
{:path "/home/sgopale/Source/code-editing-agent"}
Tool : list_directory
{:path "/home/sgopale/Source/code-editing-agent/src"}
Tool : list_directory
{:path "/home/sgopale/Source/code-editing-agent/src/agent"}
Tool : read_text_file
{:path "/home/sgopale/Source/code-editing-agent/src/agent/core.clj"}
Reasoning : Okay, so the user asked what the program in the current folder does. After listing the directories and checking the contents, I found that the main program is likely in the 'agent' directory's core.clj file. The tool response shows that core.clj is a Clojure script.
Looking at the content, it defines the namespace `agent.core`, which includes functions for interacting with LLM APIs, handling tool calls, and a main entry point `-main`. The code uses libraries like `agent.mcpclient`, `agent.state`, and `agent.tools`, which suggest it's an assistant or agent system that communicates with an LLM (like OpenAI) and handles tool invocations.
Specifically, the `-main` function initializes the system by reading configuration from `llm-ollama.edn`, getting tools from `agent.tools`, and processing user input. It uses a loop to handle user interactions, calling the LLM for responses, and managing tool calls. The code includes features like displaying responses, handling tool registration, and exiting the application.
The file also has a commented `-main` call, indicating it's meant to be run as the main entry point. The program seems to be an assistant that processes user inputs through an LLM, executes tools (like API calls or functions), and manages the conversation flow.
So putting it all together, the program is a CLI-based assistant that uses LLMs and tools to interact with users, likely part of a larger application for AI-assisted tasks.
LLM : The program in the current folder is a Clojure-based interactive agent/assistant system designed to interact with LLMs (Large Language Models) and execute tools based on user input. Below is a breakdown of its purpose and functionality:
Key Features & Purpose
1. LLM Integration
- Connects to external LLM APIs (e.g., Ollama, OpenAI) via wkok.openai-clojure.api and agent.mcpclient.
- Handles tool invocations (e.g., functions like mcpclient/get-servers) and processes responses.
2. Tool-Based Workflow
- Registers and executes custom tools (e.g., from agent.tools namespace).
- Dynamically manages tool responses (e.g., parsing tool calls, handling errors).
- Supports nested interactions where LLM output triggers tool execution, which then feeds back into the conversation.
3. User Interaction Loop
- Starts with a system prompt and reads user input via read-user-input!.
- Processes inputs through a loop:
- Calls the LLM to generate responses.
- Handles tool calls (e.g., invoke-tool).
- Displays results to the user (display-assistant-response!).
- Continues until the user exits (:quit).
4. Configuration & State Management
- Loads configuration from llm-ollama.edn (e.g., API keys, endpoints).
- Tracks conversation history (:history) and tool registry (:tools-registry).
- Maintains state across interactions (e.g., state/handle-user-input!).
5. Debugging & Logging
- Uses dbg-print for debug logs (e.g., tool registration, errors).
- Prints warnings for edge cases (e.g., multiple LLM responses).
Intended Use Case
This program is part of a CLI-friendly AI assistant system, likely used for:
- Building chatbots or interactive workflows.
- Testing LLM tool integration (e.g., connecting to APIs like Ollama).
- Processing user queries with dynamic tool chains (e.g., "Explain X, then fetch data from API Y").
It’s designed to be configurable (via llm-ollama.edn), extendable (by adding tools to agent.tools), and interactive (via read-user-input!).
Summary
The program is an LLM-driven assistant that:
- Interacts with users through a loop.
- Executes tools dynamically (e.g., API calls, custom functions).
- Handles errors and logs for debugging.
- Is configured via llm-ollama.edn and tools in agent.tools.
It’s ideal for AI-powered workflows requiring dynamic tool execution and natural language interactions. To run it, call (-main) in the Clojure REPL.
Debug : {:completion_tokens 1140, :prompt_tokens 7151, :total_tokens 8291} Response took - 44.885 s
Finally success. This model works reliably with tool calling and does a decent job of summarizing what the code does. However, it is too slow for me. It took approximately 45 seconds to process this prompt. Comparing it with an Azure hosted GPT-5 mini model, the online model takes 11 seconds. The online model is running at the cheapest config possible so can be faster if we choose to pay more.
So, even if the future of AI agents is local models. I believe we are not in that future yet, at least for coding agents which can call tools. Maybe these models are good for answering questions about code. I have not tried them for that use case. I will stick to online hosted models for my workflows.
Published: 2025-11-16
In the previous post we looked at connecting to a MCP server - specifically to the Anthropic file system MCP server. Let's extend the functionality of the coding agent to talk to multiple configurable MCP servers.
We will reuse the same format that Claude Code uses for configuring MCP servers. The file is of the format
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/Users/username/Desktop",
"/path/to/other/allowed/dir"
]
},
"sequential-thinking": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-sequential-thinking"
]
}
}
}
Each MCP server is specified with a path to an executable. We will only deal with local MCP servers, leaving remote MCP servers for another post.
We had directly invoked the tool call on the MCP client connection as we had a single MCP server and did not need to disambiguate between calls. While supporting multiple MCP servers we need to route the tool call to the correct MCP server. For doing this lets remove the direct MCP tool call and utilize the tool registry to direct the calls. Recall that we had built a tool registry to register function based tool calls. The registry was a map of the form name -> function. Let us keep the same structure, except we will bind the function to a dynamic function which calls the MCP tool as appropriate.
We will bind each MCP tool to a function of the form. The server and name are already available when creating the binding. The args are passed in by the invoke-tool call.
(fn [args] (call-tool server name args))
(defn- build-tool-registry
[client]
(let [result (.listTools client)
tools (.tools result)
tool-metadata (mapv #(mcp-tool->openai-tool (tool-result->clj %)) tools)]
(reduce (fn [acc f]
(let [name (get-in f [:function :name])]
(assoc acc name (fn [args] (call-tool client name args)))))
{} tool-metadata)))
Since, our regular tool requires parsed json arguments - we will change that function binding to convert the json string args into a Clojure map.
(defn build-tool-registry
"Builds a map of tool names to their corresponding functions"
[ns]
(->> (ns-publics ns)
vals
(filter #(:tool (meta %)))
(reduce (fn [acc f]
(let [name (get-in (meta f) [:tool :name])]
(assoc acc name (fn [args] (f (parse-json-arguments args))))))
{})))
We will force the LLM to use the thinking tools with the following prompt - "Help me write a factorial function in CLJS. Please think a bit before writing the code. Please use the tools available to you." With this prompt the LLM uses both the MCP servers available to it.
----- Registering MCP Servers ----
Connecting :sequential-thinking
Connecting :filesystem
Debug : Registering tool run_shell_command
Debug : Registering tool sequentialthinking
Debug : Registering tool read_file
Debug : Registering tool read_text_file
Debug : Registering tool read_media_file
Debug : Registering tool read_multiple_files
Debug : Registering tool write_file
Debug : Registering tool edit_file
Debug : Registering tool create_directory
Debug : Registering tool list_directory
Debug : Registering tool list_directory_with_sizes
Debug : Registering tool directory_tree
Debug : Registering tool move_file
Debug : Registering tool search_files
Debug : Registering tool get_file_info
Debug : Registering tool list_allowed_directories
You : Help me write a factorial function in CLJS. Please think a bit before writing the code. Please use the tools available to you.
----- Use of the Sequential Thinking MCP server ----
Tool : sequentialthinking
{:thoughtNumber 1,
:totalThoughts 6,
:thought
"I need to plan before coding. Decide which factorial variants to provide and consider CLJS-specific constraints: recursion vs tail recursion, JS number limits, validation of input. Provide clear examples and safe checks.",
:nextThoughtNeeded true}
Tool : sequentialthinking
{:thoughtNumber 2,
:totalThoughts 6,
:thought
"I should implement two versions: a simple recursive factorial and a tail-recursive one using ClojureScript's recur or loop/recur. CLJS supports recur but needs to be in tail position. Also consider using BigInt for large factorials? JavaScript BigInt exists but CLJS interop may be needed – use js/BigInt. Provide an arbitrary-precision variant using BigInt and one using numbers. Also include input validation: ensure n is integer >= 0.",
:nextThoughtNeeded true}
Tool : sequentialthinking
{:thoughtNumber 3,
:totalThoughts 6,
:thought
"Decide file structure: write a CLJS file with namespace, docstrings, and exported functions. Possibly provide examples/tests. Use cljs.node or browser? Keep generic. I'll create src/factorial/core.cljs with functions: factorial (number, tail-recursive using loop/recur), factorial-bigint using js/BigInt and recursive loop. Add validation functions and small test printouts in -main for Node. Use ^:export to expose functions.",
:nextThoughtNeeded true}
----- Use of the filesystem server ----
Tool : create_directory
{:path "src/factorial"}
Tool : write_file
{:path "src/factorial/core.cljs",
:content ...}
Tool : edit_file
{:path "src/factorial/core.cljs",
:edits ...}
LLM : Thanks — I thought through options before writing code and used the workspace to create a small CLJS module with two implementations and examples.
What I considered
- Provide a safe, tail-recursive implementation for ordinary JS Numbers (fast, but limited by IEEE-754 and 2^53 precision).
- Provide a BigInt-based implementation for arbitrarily large factorials using JS BigInt via interop (requires modern JS engine or a polyfill).
- Validate input (non-negative integer).
- Use loop/recur so recursion is tail-recursive and safe for reasonably large n.
What I wrote
I created src/factorial/core.cljs with:
- factorial: tail-recursive using loop/recur; returns a JS Number (double).
- factorial-bigint: tail-recursive using js/BigInt; returns a BigInt.
- run-examples and -main for quick testing.
File: src/factorial/core.cljs
(You can open this file in your project — I created it for you.)
Key functions (contents in the file):
ns factorial.core
1) factorial
- Signature: (factorial n)
- Behavior: checks that n is an integer and non-negative, then computes n! with loop/recur, returning a JS Number.
- Notes: Overflow / loss of integer precision beyond about 2^53-1 / ~170! for magnitude.
2) factorial-bigint
- Signature: (factorial-bigint n)
- Behavior: converts n to a JS BigInt (js/BigInt), checks non-negative, computes n! with loop/recur and BigInt arithmetic, returns a BigInt.
- Notes: Accepts numbers or string representations convertible to BigInt; requires environment support for BigInt (modern Node, browsers).
3) run-examples / -main
- Small helper that prints sample results:
- factorial 5 => 120
- factorial-bigint 20 => 2432902008176640000n (printed via str)
Usage examples (REPL)
- (require 'factorial.core)
- (factorial 5) => 120
- (factorial-bigint 20) => 2432902008176640000n
- (str (factorial-bigint 100)) => (string of 100!)
Notes, caveats, and tips
- Use factorial for small n where performance/primitive numbers are fine.
- Use factorial-bigint for large n and when exact integer results are required.
- BigInt interop: js/BigInt is used. On older runtimes without BigInt, you'll get an error; a BigInt polyfill or alternate big-integer library would be required.
- If you want a pure CLJS arbitrary-precision library (works everywhere), I can rewrite factorial-bigint to use a JS big-integer library (like big-integer.js via npm) or a ClojureScript big-integer library.
If you’d like, I can:
- Add tests (cljs.test).
- Provide project config / build sample (shadow-cljs or cljs.main) to run on Node or in browser.
- Add a memoized or lazy-stream implementation for many repeated factorial calls.
Would you like me to open the file contents here, add tests, or show how to compile/run it with shadow-cljs / cljs.main?
The full listing of the code is here.
Published: 2025-11-11