Created
January 28, 2026 10:23
-
-
Save serefayar/e6f1a328654ff534b3f99ca5bd9ee31a to your computer and use it in GitHub Desktop.
Minimal Agent Engine from Scratch with Clojure
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ;; ====================================================================== | |
| ;; File: simple_agent.clj | |
| ;; Title: De-mystifying Agentic AI: Building a Minimal Agent Engine from Scratch with Clojure | |
| ;; Author: Seref R. Ayar | |
| ;; Article: https://serefayar.substack.com/p/minimal-agent-engine-from-scratch-with-clojure | |
| ;; | |
| ;; ====================================================================== | |
| (add-lib 'clj-http/clj-http {:mvn/version "3.12.3"}) | |
| (add-lib 'metosin/jsonista {:mvn/version "0.3.7"}) | |
| (add-lib 'metosin/malli {:mvn/version "0.13.0"}) | |
| (require '[clj-http.client :as http] | |
| '[jsonista.core :as json] | |
| '[malli.core :as m] | |
| '[malli.json-schema :as json-schema] | |
| '[malli.error :as me])) | |
| (def api-key (System/getenv "OPENAI_API_KEY")) | |
| (def json-mapper json/keyword-keys-object-mapper) | |
| ;; ====================================================================== | |
| ;; TELEMETRY (X-Ray Vision) | |
| ;; ====================================================================== | |
| (defn with-tracing | |
| "Wraps a function with a tap> listener for observability." | |
| [node-key node-fn state] | |
| (let [start-time (System/nanoTime) | |
| result (node-fn state) ;; Execute the actual logic | |
| end-time (System/nanoTime) | |
| duration-ms (/ (- end-time start-time) 1000000.0)] | |
| ;; Send telemetry to side channel (Portal, Console, etc.) | |
| (tap> {:type :trace | |
| :node node-key | |
| :input state | |
| :output result | |
| :duration duration-ms | |
| :timestamp (System/currentTimeMillis)}) | |
| result)) | |
| ;; ====================================================================== | |
| ;; THE CORE LOOP (Communication) | |
| ;; ====================================================================== | |
| (defn call-llm | |
| "Stateless HTTP call to OpenAI." | |
| [messages] | |
| (let [body (json/write-value-as-string | |
| {:model "gpt-oss:20b" | |
| :messages messages | |
| :response_format {:type "json_object"}}) ;; Enforce JSON mode | |
| response (http/post "http://localhost:11434/v1/chat/completions" | |
| {:headers {"Authorization" (str "Bearer " api-key)} | |
| :content-type :json | |
| :accept :json | |
| :body body | |
| :as :stream})] | |
| (-> response | |
| :body | |
| (json/read-value json-mapper) | |
| (get-in [:choices 0 :message])))) | |
| ;; ====================================================================== | |
| ;; STRUCTURED OUTPUT (Self-Healing) | |
| ;; ====================================================================== | |
| (defn generate-structured | |
| "Generates valid JSON based on Malli schema with self-correction." | |
| [system-prompt user-prompt schema max-retries] | |
| (let [json-schema-str (json/write-value-as-string (json-schema/transform schema)) | |
| sys-msg {:role "system" | |
| :content (str system-prompt | |
| "\nOutput ONLY JSON strictly matching this schema:\n" | |
| json-schema-str)}] | |
| (loop [history [sys-msg {:role "user" :content user-prompt}] | |
| attempt 0] | |
| (if (> attempt max-retries) | |
| (throw (ex-info "Max retries exceeded for structured generation" {:history history})) | |
| (let [response (call-llm history) | |
| content (:content response) | |
| ;; Try to parse | |
| parsed-data (try (json/read-value content json-mapper) | |
| (catch Exception _ nil))] | |
| ;; Validate | |
| (if (and parsed-data (m/validate schema parsed-data)) | |
| parsed-data ;; Success! | |
| ;; Failure -> Feedback Loop | |
| (let [error-msg (if parsed-data | |
| (me/humanize (m/explain schema parsed-data)) | |
| "Invalid JSON syntax.")] | |
| (println " >> [Self-Healing] Correction attempt:" (inc attempt) "-" error-msg) | |
| (recur (conj history | |
| response | |
| {:role "user" :content (str "Validation Error: " error-msg ". Fix it.")}) | |
| (inc attempt))))))))) | |
| ;; ====================================================================== | |
| ;; THE GRAPH ENGINE (State Machine) | |
| ;; ====================================================================== | |
| (defn run-graph | |
| "Executes the workflow graph." | |
| [graph start-node initial-state] | |
| (println "\n--- Starting Graph Execution ---") | |
| (loop [current-node-key start-node | |
| current-state initial-state] | |
| (if (= current-node-key :END) | |
| (do (println "--- Workflow Completed ---") | |
| current-state) | |
| (let [;; 1. Fetch Node | |
| node-fn (get-in graph [:nodes current-node-key]) | |
| _ (println "-> Executing Node:" current-node-key) | |
| ;; 2. Execute with Telemetry | |
| new-state (with-tracing current-node-key node-fn current-state) | |
| ;; 3. Route | |
| edge-logic (get-in graph [:edges current-node-key]) | |
| next-node-key (edge-logic new-state)] | |
| ;; 4. Recur | |
| (recur next-node-key new-state))))) | |
| ;; ====================================================================== | |
| ;; EXAMPLE APPLICATION: BLOG WRITER AGENT | |
| ;; ====================================================================== | |
| ;; 1. Define Schemas | |
| (def BlogDraftSchema | |
| [:map | |
| [:draft_title :string] | |
| [:draft_content :string] | |
| [:word_count :int]]) | |
| (def ReviewSchema | |
| [:map | |
| [:approved? :boolean] | |
| [:critique {:optional true} :string]]) | |
| ;; 2. Define Node Functions | |
| (defn researcher-node [state] | |
| (let [prompt (str "Write a short blog post draft about: " (:topic state)) | |
| result (generate-structured "You are a writer." prompt BlogDraftSchema 3)] | |
| (merge state result))) | |
| (defn reviewer-node [state] | |
| (let [prompt (str "Review this draft:\n" (:draft_content state) | |
| "\nIf it's under 50 words, reject it. Otherwise approve.") | |
| result (generate-structured "You are an editor." prompt ReviewSchema 3)] | |
| (println " [Reviewer Decision]:" (:approved? result)) | |
| (merge state result))) | |
| (defn publisher-node [state] | |
| (println " [Publisher] Publishing to CMS...") | |
| (assoc state :published_at (str (java.time.Instant/now)))) | |
| ;; 3. Define Graph Structure | |
| (def blog-workflow | |
| {:nodes {:researcher researcher-node | |
| :reviewer reviewer-node | |
| :publisher publisher-node} | |
| :edges {:researcher (constantly :reviewer) | |
| :reviewer (fn [state] | |
| (if (:approved? state) | |
| :publisher ;; Approved -> Publish | |
| :researcher)) ;; Rejected -> Rewrite (Loop!) | |
| :publisher (constantly :END)}}) | |
| ;; ====================================================================== | |
| ;; EXECUTE! | |
| ;; ===================================================================== | |
| (defn execute! [] | |
| ;; Setup a simple tap listener to print traces to console | |
| (add-tap (fn [data] | |
| (when (= (:type data) :trace) | |
| (println (format " [TRACE] Node: %s | Duration: %.2f ms" | |
| (:node data) (:duration data)))))) | |
| ;; Run the agent | |
| (let [final-state (run-graph blog-workflow :researcher {:topic "The Future of Agentic AI"})] | |
| (println "\nFinal State:" final-state))) | |
| (comment | |
| (execute!) | |
| ) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment