Skip to content

Instantly share code, notes, and snippets.

@serefayar
Created January 28, 2026 10:23
Show Gist options
  • Select an option

  • Save serefayar/e6f1a328654ff534b3f99ca5bd9ee31a to your computer and use it in GitHub Desktop.

Select an option

Save serefayar/e6f1a328654ff534b3f99ca5bd9ee31a to your computer and use it in GitHub Desktop.
Minimal Agent Engine from Scratch with Clojure
;; ======================================================================
;; 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