Files
agent0/src/agent.clj
2026-01-16 00:13:47 -05:00

239 lines
9.3 KiB
Clojure

(ns agent
(:require [babashka.http-client :as http]
[cheshire.core :as json]
[clojure.java.io :as io]
[clojure.string :as str]))
;; ============================================================
;; Configuration
;; ============================================================
;; Ollama - local LLM server at http://localhost:11434
(def ollama-host (or (System/getenv "OLLAMA_HOST") "http://localhost:11434"))
;; Local models with tool use support:
;; - qwen2.5:1.5b (small, fast)
;; - qwen2.5:3b (better quality)
;; - llama3.2:3b (alternative)
(def model (or (System/getenv "AGENT_MODEL") "qwen2.5:1.5b"))
(def max-tokens 4096)
;; ============================================================
;; Tools Implementation
;; ============================================================
(defn read-file
"Read the contents of a file at the given path."
[{:keys [path]}]
(let [file (io/file path)]
(if (.exists file)
(slurp file)
(str "Error: File not found: " path))))
(defn list-files
"List files in a directory. Directories have trailing slashes."
[{:keys [path]}]
(let [dir (io/file (or path "."))]
(if (.isDirectory dir)
(->> (.listFiles dir)
(map (fn [f]
(if (.isDirectory f)
(str (.getName f) "/")
(.getName f))))
(sort)
(str/join "\n"))
(str "Error: Not a directory: " path))))
(defn edit-file
"Edit a file by replacing old_str with new_str."
[{:keys [path old_str new_str]}]
(let [file (io/file path)]
(if (.exists file)
(let [content (slurp file)
occurrences (count (re-seq (re-pattern (java.util.regex.Pattern/quote old_str)) content))]
(cond
(zero? occurrences)
(str "Error: old_str not found in file: " path)
(> occurrences 1)
(str "Error: old_str appears " occurrences " times. Must be unique.")
:else
(do
(spit file (str/replace-first content old_str new_str))
(str "Successfully edited " path))))
(str "Error: File not found: " path))))
(defn create-file
"Create a new file with the given content."
[{:keys [path content]}]
(let [file (io/file path)]
(if (.exists file)
(str "Error: File already exists: " path)
(do
(io/make-parents file)
(spit file content)
(str "Successfully created " path)))))
;; ============================================================
;; Tool Registry (OpenAI format)
;; ============================================================
(def tools
[{:type "function"
:function {:name "read_file"
:description "Read the contents of a given relative file path. Use this to examine existing files."
:parameters {:type "object"
:properties {:path {:type "string"
:description "The relative path to the file to read"}}
:required ["path"]}}
:impl read-file}
{:type "function"
:function {:name "list_files"
:description "List files and directories at a given path. Directories have trailing slashes. Use this to explore the project structure."
:parameters {:type "object"
:properties {:path {:type "string"
:description "The relative path to list (defaults to current directory)"}}
:required []}}
:impl list-files}
{:type "function"
:function {:name "edit_file"
:description "Edit a file by replacing old_str with new_str. The old_str must appear exactly once in the file."
:parameters {:type "object"
:properties {:path {:type "string"
:description "The relative path to the file to edit"}
:old_str {:type "string"
:description "The exact text to replace (must be unique in the file)"}
:new_str {:type "string"
:description "The new text to insert"}}
:required ["path" "old_str" "new_str"]}}
:impl edit-file}
{:type "function"
:function {:name "create_file"
:description "Create a new file with the given content. Will fail if the file already exists."
:parameters {:type "object"
:properties {:path {:type "string"
:description "The relative path for the new file"}
:content {:type "string"
:description "The content to write to the file"}}
:required ["path" "content"]}}
:impl create-file}])
(def tool-map
(into {} (map (fn [t] [(get-in t [:function :name]) (:impl t)]) tools)))
(def tool-definitions
(mapv #(dissoc % :impl) tools))
;; ============================================================
;; Ollama API
;; ============================================================
(defn call-llm
"Send messages to Ollama API and return the response."
[messages system-prompt]
(let [body {:model model
:options {:num_predict max-tokens}
:messages (into [{:role "system" :content system-prompt}] messages)
:tools tool-definitions
:stream false}
response (http/post (str ollama-host "/api/chat")
{:headers {"Content-Type" "application/json"}
:body (json/generate-string body)})]
;; Ollama returns a single message object, wrap it to match OpenAI format
(let [result (json/parse-string (:body response) true)]
{:choices [{:message (:message result)
:finish_reason (if (seq (get-in result [:message :tool_calls]))
"tool_calls"
"stop")}]})))
;; ============================================================
;; Agent Loop
;; ============================================================
(defn extract-tool-calls
"Extract tool_calls from the assistant message."
[message]
(:tool_calls message))
(defn execute-tool
"Execute a tool and return the result message."
[{:keys [id function]}]
(let [tool-name (:name function)
raw-args (:arguments function)
;; Ollama returns args as a map, OpenAI returns as JSON string
args (if (string? raw-args)
(json/parse-string raw-args true)
raw-args)]
(println (str " → Tool: " tool-name " " (pr-str args)))
(let [tool-fn (get tool-map tool-name)
result (if tool-fn
(tool-fn args)
(str "Error: Unknown tool: " tool-name))]
{:role "tool"
:tool_call_id id
:content result})))
(defn run-agent
"Run the agent loop with the given initial prompt."
[prompt]
(println (str "Using model: " model " via " ollama-host))
(let [system-prompt "You are a helpful coding assistant. You can read, list, create, and edit files to help the user with their coding tasks. Always explain what you're doing before using tools. Use the tools when needed to complete the task."]
(loop [messages [{:role "user" :content prompt}]
iteration 0]
(when (> iteration 50)
(println "Max iterations reached. Stopping.")
(System/exit 1))
(println (str "\n[Iteration " iteration "]"))
(let [response (call-llm messages system-prompt)
_ (when-let [err (:error response)]
(println "API Error:" (or (:message err) (pr-str err)))
(System/exit 1))
choice (first (:choices response))
message (:message choice)
finish-reason (:finish_reason choice)
content (:content message)
tool-calls (extract-tool-calls message)]
;; Print assistant's text response
(when (and content (seq content))
(println)
(println content))
(if (and (= finish-reason "tool_calls") (seq tool-calls))
;; Execute tools and continue
(let [tool-results (mapv execute-tool tool-calls)
assistant-msg (select-keys message [:role :content :tool_calls])
new-messages (into (conj messages assistant-msg) tool-results)]
(recur new-messages (inc iteration)))
;; Done
(do
(println "\n[Done]")
response))))))
;; ============================================================
;; Main Entry Point
;; ============================================================
(defn -main [& args]
(if (empty? args)
(do
(println "agent0 - A simple coding agent (powered by Ollama)")
(println)
(println "Usage: ./agent <prompt>")
(println "Example: ./agent \"List all files in this directory\"")
(println)
(println "Environment variables:")
(println " OLLAMA_HOST - Ollama server URL (default: http://localhost:11434)")
(println " AGENT_MODEL - Model to use (default: qwen2.5:1.5b)"))
(run-agent (str/join " " args))))
(when (= *file* (System/getProperty "babashka.file"))
(apply -main *command-line-args*))