add websearch
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bb
|
#!/usr/bin/env bb
|
||||||
|
|
||||||
(require '[agent :as agent])
|
(require '[agent.app :as app])
|
||||||
|
|
||||||
(apply agent/-main *command-line-args*)
|
(apply app/-main *command-line-args*)
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
{:paths ["src"]}
|
{:paths ["src"]
|
||||||
|
:deps {tui/tui {:local/root "../clojure-tui"}}}
|
||||||
|
|||||||
-347
@@ -1,347 +0,0 @@
|
|||||||
(ns agent
|
|
||||||
(:require [babashka.http-client :as http]
|
|
||||||
[cheshire.core :as json]
|
|
||||||
[clojure.java.io :as io]
|
|
||||||
[clojure.string :as str])
|
|
||||||
(:import [java.time LocalDateTime]
|
|
||||||
[java.time.format DateTimeFormatter]))
|
|
||||||
|
|
||||||
;; ============================================================
|
|
||||||
;; Configuration
|
|
||||||
;; ============================================================
|
|
||||||
|
|
||||||
;; Ollama - local LLM server at http://localhost:11434
|
|
||||||
(def ollama-host (or (System/getenv "OLLAMA_HOST") "http://localhost:11434"))
|
|
||||||
|
|
||||||
;; Local model with tool use support
|
|
||||||
(def model (or (System/getenv "AGENT_MODEL") "qwen3-coder-next"))
|
|
||||||
(def max-tokens 8192)
|
|
||||||
|
|
||||||
;; ============================================================
|
|
||||||
;; Logging
|
|
||||||
;; ============================================================
|
|
||||||
|
|
||||||
(def log-dir
|
|
||||||
(let [home (System/getProperty "user.home")]
|
|
||||||
(.getPath (io/file home ".local" "share" "agent0" "logs"))))
|
|
||||||
(def log-file
|
|
||||||
(let [ts (.format (LocalDateTime/now) (DateTimeFormatter/ofPattern "yyyy-MM-dd_HH-mm-ss"))]
|
|
||||||
(io/file log-dir (str "agent-" ts ".log"))))
|
|
||||||
|
|
||||||
(defn init-log! []
|
|
||||||
(.mkdirs (io/file log-dir))
|
|
||||||
(spit log-file ""))
|
|
||||||
|
|
||||||
(defn log
|
|
||||||
"Write a debug message to the log file."
|
|
||||||
[& args]
|
|
||||||
(let [ts (.format (LocalDateTime/now) (DateTimeFormatter/ofPattern "HH:mm:ss.SSS"))
|
|
||||||
line (str "[" ts "] " (str/join " " args) "\n")]
|
|
||||||
(spit log-file line :append true)))
|
|
||||||
|
|
||||||
;; ============================================================
|
|
||||||
;; 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- format-size
|
|
||||||
"Format file size in human-readable form."
|
|
||||||
[size]
|
|
||||||
(cond
|
|
||||||
(< size 1024) (str size "B")
|
|
||||||
(< size (* 1024 1024)) (format "%.1fK" (/ size 1024.0))
|
|
||||||
:else (format "%.1fM" (/ size (* 1024.0 1024.0)))))
|
|
||||||
|
|
||||||
(defn list-files
|
|
||||||
"List files in a directory with metadata (type, size, permissions)."
|
|
||||||
[{:keys [path]}]
|
|
||||||
(let [dir (io/file (or path "."))]
|
|
||||||
(if (.isDirectory dir)
|
|
||||||
(->> (.listFiles dir)
|
|
||||||
(map (fn [f]
|
|
||||||
(cond
|
|
||||||
(.isDirectory f)
|
|
||||||
(str (.getName f) "/")
|
|
||||||
|
|
||||||
(.canExecute f)
|
|
||||||
(str (.getName f) " [executable, " (format-size (.length f)) "]")
|
|
||||||
|
|
||||||
:else
|
|
||||||
(str (.getName f) " [" (format-size (.length 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)))))
|
|
||||||
|
|
||||||
(defn run-shell-command
|
|
||||||
"Run a shell command and return its output."
|
|
||||||
[{:keys [command]}]
|
|
||||||
(let [proc (-> (ProcessBuilder. ["bash" "-c" command])
|
|
||||||
(.redirectErrorStream true)
|
|
||||||
.start)
|
|
||||||
output (slurp (.getInputStream proc))
|
|
||||||
exit-code (.waitFor proc)]
|
|
||||||
(if (zero? exit-code)
|
|
||||||
output
|
|
||||||
(str output "\n[exit code: " exit-code "]"))))
|
|
||||||
|
|
||||||
;; ============================================================
|
|
||||||
;; 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 /. Executable files are marked [executable, size]. Regular files show [size]. 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}
|
|
||||||
|
|
||||||
{:type "function"
|
|
||||||
:function {:name "run_shell_command"
|
|
||||||
:description "Run a shell command and return its stdout/stderr output. Use this for tasks like counting lines, searching text, running tests, or any other shell operation."
|
|
||||||
:parameters {:type "object"
|
|
||||||
:properties {:command {:type "string"
|
|
||||||
:description "The shell command to execute"}}
|
|
||||||
:required ["command"]}}
|
|
||||||
:impl run-shell-command}])
|
|
||||||
|
|
||||||
(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- truncate
|
|
||||||
"Truncate string to max-len, appending ... if truncated."
|
|
||||||
[s max-len]
|
|
||||||
(if (> (count s) max-len)
|
|
||||||
(str (subs s 0 max-len) "... [truncated, " (count s) " chars total]")
|
|
||||||
s))
|
|
||||||
|
|
||||||
(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)]
|
|
||||||
(log "→ 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 "'. Available tools: "
|
|
||||||
(str/join ", " (keys tool-map))))]
|
|
||||||
(log "← Result:" (truncate (str/replace (str result) #"\n" "\\n") 200))
|
|
||||||
{:role "tool"
|
|
||||||
:tool_call_id id
|
|
||||||
:content result})))
|
|
||||||
|
|
||||||
(defn- tool-call-signature
|
|
||||||
"Return a comparable signature for a tool call to detect repetition."
|
|
||||||
[{:keys [function]}]
|
|
||||||
[(:name function) (:arguments function)])
|
|
||||||
|
|
||||||
(defn- detect-stuck-loop
|
|
||||||
"Check if the last N tool calls are identical, indicating a stuck loop.
|
|
||||||
Returns the repeated tool name if stuck, nil otherwise."
|
|
||||||
[tool-calls previous-signatures repeat-threshold]
|
|
||||||
(let [current-sigs (mapv tool-call-signature tool-calls)
|
|
||||||
all-sigs (conj previous-signatures current-sigs)]
|
|
||||||
{:signatures all-sigs
|
|
||||||
:stuck? (when (>= (count all-sigs) repeat-threshold)
|
|
||||||
(let [recent (take-last repeat-threshold all-sigs)]
|
|
||||||
(when (apply = recent)
|
|
||||||
(ffirst (last recent)))))}))
|
|
||||||
|
|
||||||
(defn run-agent
|
|
||||||
"Run the agent loop with the given initial prompt."
|
|
||||||
[prompt]
|
|
||||||
(init-log!)
|
|
||||||
(log "Session started | model:" model "| host:" ollama-host)
|
|
||||||
(log "Log file:" (.getPath log-file))
|
|
||||||
(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."
|
|
||||||
repeat-threshold 3]
|
|
||||||
(loop [messages [{:role "user" :content prompt}]
|
|
||||||
iteration 0
|
|
||||||
tool-sigs []]
|
|
||||||
(when (> iteration 50)
|
|
||||||
(log "Max iterations (50) reached. Stopping.")
|
|
||||||
(println "\nStopping: too many iterations.")
|
|
||||||
(System/exit 1))
|
|
||||||
|
|
||||||
(log "Iteration" iteration "| messages:" (count messages))
|
|
||||||
|
|
||||||
(let [response (call-llm messages system-prompt)
|
|
||||||
_ (when-let [err (:error response)]
|
|
||||||
(log "API error:" (or (:message err) (pr-str err)))
|
|
||||||
(println "Error: could not reach the model.")
|
|
||||||
(System/exit 1))
|
|
||||||
choice (first (:choices response))
|
|
||||||
message (:message choice)
|
|
||||||
finish-reason (:finish_reason choice)
|
|
||||||
content (:content message)
|
|
||||||
tool-calls (extract-tool-calls message)]
|
|
||||||
|
|
||||||
(log "finish_reason:" finish-reason "| tool_calls:" (count (or tool-calls [])))
|
|
||||||
|
|
||||||
;; 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 — but check for stuck loops first
|
|
||||||
(let [{:keys [signatures stuck?]} (detect-stuck-loop tool-calls tool-sigs repeat-threshold)]
|
|
||||||
(if stuck?
|
|
||||||
(do
|
|
||||||
(log "Stuck loop detected:" stuck? "repeated" repeat-threshold "times")
|
|
||||||
(println "\nStopping: agent is repeating the same action.")
|
|
||||||
response)
|
|
||||||
(let [_ (doseq [tc tool-calls]
|
|
||||||
(let [tname (get-in tc [:function :name])
|
|
||||||
raw-args (get-in tc [:function :arguments])
|
|
||||||
args (if (string? raw-args)
|
|
||||||
(json/parse-string raw-args true)
|
|
||||||
raw-args)]
|
|
||||||
(println (str " " (case tname
|
|
||||||
"read_file" (str "Reading " (:path args))
|
|
||||||
"list_files" (str "Listing " (or (:path args) "."))
|
|
||||||
"edit_file" (str "Editing " (:path args))
|
|
||||||
"create_file" (str "Creating " (:path args))
|
|
||||||
"run_shell_command" (str "Running `" (:command args) "`")
|
|
||||||
(str "Calling " tname))))))
|
|
||||||
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) signatures))))
|
|
||||||
|
|
||||||
;; Done
|
|
||||||
(do
|
|
||||||
(log "Agent finished after" iteration "iterations")
|
|
||||||
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: qwen3-coder-next)"))
|
|
||||||
(run-agent (str/join " " args))))
|
|
||||||
|
|
||||||
(when (= *file* (System/getProperty "babashka.file"))
|
|
||||||
(apply -main *command-line-args*))
|
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
(ns agent.app
|
||||||
|
(:require [agent.core :as core]
|
||||||
|
[agent.markdown :as md]
|
||||||
|
[tui.core :as tui]
|
||||||
|
[tui.events :as ev]
|
||||||
|
[tui.terminal :as term]
|
||||||
|
[clojure.string :as str]))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Agent Event Queue
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(defn- make-event-queue [] (atom []))
|
||||||
|
|
||||||
|
(defn- drain-events! [event-queue]
|
||||||
|
(let [events @event-queue]
|
||||||
|
(reset! event-queue [])
|
||||||
|
events))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Text Utilities
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(defn- wrap-line
|
||||||
|
"Word-wrap a single line to fit within max-width."
|
||||||
|
[line max-width]
|
||||||
|
(if (<= (count line) max-width)
|
||||||
|
[line]
|
||||||
|
(loop [[word & remaining] (str/split line #" ")
|
||||||
|
current ""
|
||||||
|
result []]
|
||||||
|
(if-not word
|
||||||
|
(if (empty? current) result (conj result current))
|
||||||
|
(if (empty? current)
|
||||||
|
(if (> (count word) max-width)
|
||||||
|
;; Word itself too long — hard break
|
||||||
|
(let [head (subs word 0 max-width)
|
||||||
|
tail (subs word max-width)]
|
||||||
|
(recur (cons tail remaining) "" (conj result head)))
|
||||||
|
(recur remaining word result))
|
||||||
|
(let [new-line (str current " " word)]
|
||||||
|
(if (> (count new-line) max-width)
|
||||||
|
(recur (cons word remaining) "" (conj result current))
|
||||||
|
(recur remaining new-line result))))))))
|
||||||
|
|
||||||
|
(defn- wrap-text
|
||||||
|
"Word-wrap text, respecting existing newlines."
|
||||||
|
[text max-width]
|
||||||
|
(if (or (nil? text) (empty? text))
|
||||||
|
[""]
|
||||||
|
(vec (mapcat #(if (empty? %) [""] (wrap-line % max-width))
|
||||||
|
(str/split-lines text)))))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Message Formatting
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(def ^:private spinner-frames ["⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"])
|
||||||
|
|
||||||
|
(defn- strip-ansi
|
||||||
|
"Strip ANSI escape codes to get visible length."
|
||||||
|
[s]
|
||||||
|
(str/replace s #"\033\[[0-9;]*m" ""))
|
||||||
|
|
||||||
|
(defn- header-lines
|
||||||
|
"Generate inline header box with ASCII Clojure logo and welcome message."
|
||||||
|
[width]
|
||||||
|
(let [p "\033[38;5;135m"
|
||||||
|
w "\033[1;97m"
|
||||||
|
d "\033[2m"
|
||||||
|
r "\033[0m"
|
||||||
|
box-w (min 48 (max 24 (- width 4)))
|
||||||
|
iw (- box-w 2)
|
||||||
|
|
||||||
|
;; Hat lines padded to 20 chars to align with 20-char braille logo
|
||||||
|
lines [(str p " ✦ " r)
|
||||||
|
(str p " ▄█▄ " r)
|
||||||
|
(str p " ▄█████▄ " r)
|
||||||
|
(str p " ▀▀▀▀▀▀▀▀▀ " r)
|
||||||
|
;; Clojure logo - generated from SVG via chafa (20x10 braille, 256-color)
|
||||||
|
" \033[38;5;255m⢀\033[38;5;231m⣠\033[38;5;254m⣴\033[38;5;189m⣶\033[38;5;146m⣿⣿⣿⣿\033[38;5;189m⣶\033[38;5;254m⣦\033[38;5;231m⣄\033[38;5;255m⡀\033[0m "
|
||||||
|
" \033[38;5;246m⣠\033[38;5;253m⣶\033[38;5;147m⣿\033[38;5;68m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\033[38;5;146m⣿\033[38;5;188m⣶\033[38;5;246m⣄\033[0m "
|
||||||
|
" \033[38;5;253m⣴\033[38;5;231m⣿\033[38;5;189m⣿\033[38;5;254m⣿⣿⣿\033[38;5;153m⣿\033[38;5;189m⣿\033[38;5;153m⣿⣿⣿\033[38;5;147m⣿\033[38;5;104m⣿\033[38;5;68m⣿⣿⣿\033[38;5;153m⣿\033[38;5;253m⣦\033[0m "
|
||||||
|
"\033[38;5;250m⣸\033[38;5;255m⣿\033[38;5;150m⣿\033[38;5;71m⣿⣿\033[38;5;187m⣿\033[38;5;156m⣿\033[38;5;149m⣿\033[38;5;194m⣿\033[38;5;153m⣿\033[38;5;111m⣿⣿⣿\033[38;5;153m⣿\033[38;5;189m⣿\033[38;5;68m⣿⣿⣿\033[38;5;153m⣿\033[38;5;250m⣇\033[0m"
|
||||||
|
"\033[38;5;231m⣿\033[38;5;107m⣿\033[38;5;71m⣿⣿\033[38;5;151m⣿\033[38;5;150m⣿\033[38;5;113m⣿⣿⣿\033[38;5;255m⣿\033[38;5;147m⣿\033[38;5;111m⣿⣿⣿\033[38;5;153m⣿⣿\033[38;5;68m⣿⣿⣿\033[38;5;231m⣿\033[0m"
|
||||||
|
"\033[38;5;231m⣿\033[38;5;71m⣿⣿⣿\033[38;5;151m⣿\033[38;5;150m⣿\033[38;5;113m⣿⣿\033[38;5;193m⣿⣿\033[38;5;255m⣿\033[38;5;111m⣿⣿⣿\033[38;5;153m⣿⣿\033[38;5;68m⣿⣿⣿\033[38;5;231m⣿\033[0m"
|
||||||
|
"\033[38;5;253m⢹\033[38;5;150m⣿\033[38;5;71m⣿⣿\033[38;5;107m⣿\033[38;5;194m⣿\033[38;5;150m⣿⣿\033[38;5;193m⣿\033[38;5;113m⣿\033[38;5;193m⣿\033[38;5;189m⣿\033[38;5;111m⣿\033[38;5;153m⣿\033[38;5;189m⣿\033[38;5;68m⣿⣿\033[38;5;110m⣿\033[38;5;189m⣿\033[38;5;253m⡏\033[0m"
|
||||||
|
" \033[38;5;254m⢻\033[38;5;150m⣿\033[38;5;71m⣿⣿⣿\033[38;5;150m⣿\033[38;5;187m⣿⣿\033[38;5;193m⣿⣿\033[38;5;194m⣿\033[38;5;253m⣿⣿\033[38;5;252m⣿⣿\033[38;5;253m⣿\033[38;5;231m⣿\033[38;5;254m⡟\033[0m "
|
||||||
|
" \033[38;5;231m⠙\033[38;5;254m⢿\033[38;5;150m⣿\033[38;5;71m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\033[38;5;150m⣿\033[38;5;194m⡿\033[38;5;231m⠋\033[0m "
|
||||||
|
" \033[38;5;188m⠉\033[38;5;231m⠛\033[38;5;187m⠿\033[38;5;150m⣿⣿⣿⣿⣿⣿\033[38;5;187m⠿\033[38;5;231m⠛\033[38;5;188m⠉\033[0m "
|
||||||
|
""
|
||||||
|
(str w " agent0" r)
|
||||||
|
""
|
||||||
|
" Welcome! Type a message"
|
||||||
|
" and press Enter to chat."
|
||||||
|
""
|
||||||
|
(str d " Esc to interrupt · Ctrl+C to quit" r)]
|
||||||
|
|
||||||
|
pad-line
|
||||||
|
(fn [s]
|
||||||
|
(let [vis (count (strip-ansi s))
|
||||||
|
lp (max 0 (quot (- iw vis) 2))
|
||||||
|
rp (max 0 (- iw vis lp))]
|
||||||
|
(str d "│" r
|
||||||
|
(apply str (repeat lp \space))
|
||||||
|
s
|
||||||
|
(apply str (repeat rp \space))
|
||||||
|
d "│" r)))
|
||||||
|
|
||||||
|
h (apply str (repeat iw "─"))
|
||||||
|
top (str d "╭" h "╮" r)
|
||||||
|
bot (str d "╰" h "╯" r)
|
||||||
|
|
||||||
|
all (concat [top (pad-line "")]
|
||||||
|
(map pad-line lines)
|
||||||
|
[(pad-line "") bot])
|
||||||
|
|
||||||
|
lp (max 0 (quot (- width box-w) 2))
|
||||||
|
prefix (apply str (repeat lp \space))]
|
||||||
|
|
||||||
|
(mapv (fn [line] [:text (str prefix line)]) all)))
|
||||||
|
|
||||||
|
(defn- format-messages
|
||||||
|
"Convert display messages to a flat vector of hiccup line elements."
|
||||||
|
[messages width]
|
||||||
|
(let [lines
|
||||||
|
(mapcat
|
||||||
|
(fn [msg]
|
||||||
|
(case (:role msg)
|
||||||
|
:user
|
||||||
|
(let [prefix "You: "
|
||||||
|
wrapped (wrap-text (:content msg) (- width (count prefix)))]
|
||||||
|
(cons
|
||||||
|
[:text ""]
|
||||||
|
(map-indexed
|
||||||
|
(fn [i line]
|
||||||
|
(if (zero? i)
|
||||||
|
[:text {:fg :cyan :bold true} (str prefix line)]
|
||||||
|
[:text {:fg :cyan} (str " " line)]))
|
||||||
|
wrapped)))
|
||||||
|
|
||||||
|
:assistant
|
||||||
|
(let [lines (md/render-markdown (:content msg) width)]
|
||||||
|
(cons
|
||||||
|
[:text ""]
|
||||||
|
(map (fn [line] [:text line]) lines)))
|
||||||
|
|
||||||
|
:tool
|
||||||
|
[[:text {:dim true} (str " ⚙ " (:content msg))]]
|
||||||
|
|
||||||
|
:error
|
||||||
|
[[:text {:fg :red} (str " ✗ " (:content msg))]]
|
||||||
|
|
||||||
|
[]))
|
||||||
|
messages)]
|
||||||
|
;; Remove leading blank lines
|
||||||
|
(vec (drop-while #(= % [:text ""]) lines))))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; View
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(defn- view [{:keys [messages input agent-running? spinner-frame scroll-offset]}]
|
||||||
|
(let [{term-w :width term-h :height} (term/get-terminal-size)
|
||||||
|
width (or term-w 80)
|
||||||
|
height (or term-h 24)
|
||||||
|
input-box-height 3
|
||||||
|
chat-height (max 1 (- height input-box-height))
|
||||||
|
content-width (max 10 (- width 2))
|
||||||
|
|
||||||
|
;; Format messages with inline header
|
||||||
|
all-lines
|
||||||
|
(let [header (header-lines width)]
|
||||||
|
(if (empty? messages)
|
||||||
|
header
|
||||||
|
(into header (format-messages messages content-width))))
|
||||||
|
|
||||||
|
;; Add thinking indicator
|
||||||
|
all-lines
|
||||||
|
(if agent-running?
|
||||||
|
(conj all-lines
|
||||||
|
[:text {:fg :yellow}
|
||||||
|
(str " " (nth spinner-frames (mod spinner-frame (count spinner-frames)))
|
||||||
|
" Thinking...")])
|
||||||
|
all-lines)
|
||||||
|
|
||||||
|
;; Scroll: offset from bottom (0 = at bottom)
|
||||||
|
scroll-offset (max 0 (or scroll-offset 0))
|
||||||
|
total (count all-lines)
|
||||||
|
max-scroll (max 0 (- total chat-height))
|
||||||
|
clamped-offset (min scroll-offset max-scroll)
|
||||||
|
|
||||||
|
;; Calculate visible window
|
||||||
|
end-idx (- total clamped-offset)
|
||||||
|
start-idx (max 0 (- end-idx chat-height))
|
||||||
|
visible (subvec (vec all-lines) start-idx end-idx)
|
||||||
|
|
||||||
|
;; Pad top to push content to bottom of chat area
|
||||||
|
pad-count (max 0 (- chat-height (count visible)))
|
||||||
|
display-lines (into (vec (repeat pad-count "")) visible)
|
||||||
|
|
||||||
|
;; Truncate long input for display
|
||||||
|
max-input-width (max 1 (- width 6))
|
||||||
|
display-input (if (> (count input) max-input-width)
|
||||||
|
(subs input (- (count input) max-input-width))
|
||||||
|
input)]
|
||||||
|
|
||||||
|
[:col {:heights [chat-height input-box-height]}
|
||||||
|
(into [:col] display-lines)
|
||||||
|
[:box {:border :rounded :width :fill}
|
||||||
|
(if (pos? clamped-offset)
|
||||||
|
[:text {:fg :cyan} (str "↑" clamped-offset " " display-input "█")]
|
||||||
|
[:text {:fg :green} (str "> " display-input "█")])]]))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Update
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(defn- save-current-session! [{:keys [session-id conversation messages created]}]
|
||||||
|
(when session-id
|
||||||
|
(core/save-session! session-id
|
||||||
|
{:created created
|
||||||
|
:conversation conversation
|
||||||
|
:messages messages})))
|
||||||
|
|
||||||
|
(defn- process-agent-event
|
||||||
|
"Process a single event from the agent background loop."
|
||||||
|
[model event]
|
||||||
|
(case (:type event)
|
||||||
|
:text (update model :messages conj {:role :assistant :content (:content event)})
|
||||||
|
:tool (update model :messages conj {:role :tool :content (:label event)})
|
||||||
|
:error (update model :messages conj {:role :error :content (:message event)})
|
||||||
|
:done (let [m (assoc model
|
||||||
|
:agent-running? false
|
||||||
|
:conversation (:conversation event))]
|
||||||
|
(save-current-session! m)
|
||||||
|
m)
|
||||||
|
model))
|
||||||
|
|
||||||
|
(defn- update-fn [{:keys [model event]}]
|
||||||
|
(cond
|
||||||
|
;; Quit
|
||||||
|
(or (ev/key= event \c #{:ctrl})
|
||||||
|
(ev/key= event \d #{:ctrl}))
|
||||||
|
(do (save-current-session! model)
|
||||||
|
{:model model :events [(ev/quit)]})
|
||||||
|
|
||||||
|
;; Interrupt agent (Escape while running)
|
||||||
|
(and (ev/key= event :escape)
|
||||||
|
(:agent-running? model))
|
||||||
|
(do
|
||||||
|
(when-let [handle (:agent-handle model)]
|
||||||
|
(reset! (:cancel! handle) true)
|
||||||
|
(future-cancel (:future handle)))
|
||||||
|
{:model model})
|
||||||
|
|
||||||
|
;; Submit message (Enter, non-empty, agent not busy)
|
||||||
|
(and (ev/key= event :enter)
|
||||||
|
(seq (:input model))
|
||||||
|
(not (:agent-running? model)))
|
||||||
|
(let [text (:input model)
|
||||||
|
new-messages (conj (:messages model) {:role :user :content text})
|
||||||
|
new-conversation (conj (:conversation model) {:role "user" :content text})
|
||||||
|
agent-handle (core/run-agent-loop! new-conversation (:event-queue model))]
|
||||||
|
{:model (assoc model
|
||||||
|
:messages new-messages
|
||||||
|
:input ""
|
||||||
|
:conversation new-conversation
|
||||||
|
:agent-running? true
|
||||||
|
:agent-handle agent-handle
|
||||||
|
:spinner-frame 0
|
||||||
|
:scroll-offset 0)
|
||||||
|
:events [(ev/delayed-event 100 {:type :poll})
|
||||||
|
(ev/delayed-event 80 {:type :spinner})]})
|
||||||
|
|
||||||
|
;; Backspace
|
||||||
|
(ev/key= event :backspace)
|
||||||
|
{:model (update model :input #(if (empty? %) % (subs % 0 (dec (count %)))))}
|
||||||
|
|
||||||
|
;; Scroll up (arrow)
|
||||||
|
(ev/key= event :up)
|
||||||
|
{:model (update model :scroll-offset (fnil + 0) 3)}
|
||||||
|
|
||||||
|
;; Scroll down (arrow)
|
||||||
|
(ev/key= event :down)
|
||||||
|
{:model (update model :scroll-offset #(max 0 (- (or % 0) 3)))}
|
||||||
|
|
||||||
|
;; Scroll up (page)
|
||||||
|
(ev/key= event :page-up)
|
||||||
|
{:model (update model :scroll-offset (fnil + 0) 15)}
|
||||||
|
|
||||||
|
;; Scroll down (page)
|
||||||
|
(ev/key= event :page-down)
|
||||||
|
{:model (update model :scroll-offset #(max 0 (- (or % 0) 15)))}
|
||||||
|
|
||||||
|
;; Jump to bottom
|
||||||
|
(ev/key= event :end)
|
||||||
|
{:model (assoc model :scroll-offset 0)}
|
||||||
|
|
||||||
|
;; Jump to top
|
||||||
|
(ev/key= event :home)
|
||||||
|
{:model (assoc model :scroll-offset 999999)}
|
||||||
|
|
||||||
|
;; Poll agent event queue
|
||||||
|
(= (:type event) :poll)
|
||||||
|
(let [events (drain-events! (:event-queue model))
|
||||||
|
new-model (reduce process-agent-event model events)]
|
||||||
|
{:model new-model
|
||||||
|
:events (when (:agent-running? new-model)
|
||||||
|
[(ev/delayed-event 100 {:type :poll})])})
|
||||||
|
|
||||||
|
;; Spinner animation tick
|
||||||
|
(= (:type event) :spinner)
|
||||||
|
(if (:agent-running? model)
|
||||||
|
{:model (update model :spinner-frame inc)
|
||||||
|
:events [(ev/delayed-event 80 {:type :spinner})]}
|
||||||
|
{:model model})
|
||||||
|
|
||||||
|
;; Regular character input (letters, numbers, punctuation, space)
|
||||||
|
(and (= (:type event) :key)
|
||||||
|
(char? (:key event))
|
||||||
|
(not (contains? (:modifiers event) :ctrl))
|
||||||
|
(not (contains? (:modifiers event) :alt)))
|
||||||
|
(let [ch (if (contains? (:modifiers event) :shift)
|
||||||
|
(Character/toUpperCase (char (:key event)))
|
||||||
|
(:key event))]
|
||||||
|
{:model (update model :input str ch)})
|
||||||
|
|
||||||
|
;; Ignore everything else
|
||||||
|
:else {:model model}))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Main
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(defn- parse-args [args]
|
||||||
|
(loop [[arg & rest] args
|
||||||
|
opts {}]
|
||||||
|
(cond
|
||||||
|
(nil? arg) opts
|
||||||
|
(= arg "--continue") (recur rest (assoc opts :continue? true))
|
||||||
|
(= arg "--session") (let [[id & rest'] rest]
|
||||||
|
(recur rest' (assoc opts :session-id id)))
|
||||||
|
:else (assoc opts :prompt (str/join " " (cons arg rest))))))
|
||||||
|
|
||||||
|
(defn -main [& args]
|
||||||
|
(let [{:keys [continue? session-id prompt]} (parse-args args)
|
||||||
|
;; Resolve session to resume
|
||||||
|
resume-id (cond
|
||||||
|
session-id session-id
|
||||||
|
continue? (core/latest-session-id))
|
||||||
|
resumed (when resume-id (core/load-session resume-id))
|
||||||
|
;; Session ID: reuse existing or generate new
|
||||||
|
sid (or resume-id (core/new-session-id))
|
||||||
|
created (or (:created resumed) (str (java.time.Instant/now)))
|
||||||
|
;; Build initial state from resumed session or fresh
|
||||||
|
base-conversation (or (:conversation resumed) [])
|
||||||
|
base-messages (or (:messages resumed) [])
|
||||||
|
eq (make-event-queue)
|
||||||
|
;; If there's a prompt, append it and start agent loop
|
||||||
|
[conversation messages start?]
|
||||||
|
(if prompt
|
||||||
|
[(conj base-conversation {:role "user" :content prompt})
|
||||||
|
(conj base-messages {:role :user :content prompt})
|
||||||
|
true]
|
||||||
|
[base-conversation base-messages false])
|
||||||
|
agent-handle (when start? (core/run-agent-loop! conversation eq))
|
||||||
|
initial-model {:messages messages
|
||||||
|
:input ""
|
||||||
|
:conversation conversation
|
||||||
|
:event-queue eq
|
||||||
|
:session-id sid
|
||||||
|
:created created
|
||||||
|
:agent-running? start?
|
||||||
|
:agent-handle agent-handle
|
||||||
|
:spinner-frame 0
|
||||||
|
:scroll-offset 0}
|
||||||
|
initial-events (when start?
|
||||||
|
[(ev/delayed-event 100 {:type :poll})
|
||||||
|
(ev/delayed-event 80 {:type :spinner})])]
|
||||||
|
(tui/run
|
||||||
|
{:init initial-model
|
||||||
|
:update update-fn
|
||||||
|
:view view
|
||||||
|
:fps 30
|
||||||
|
:init-events initial-events})
|
||||||
|
;; Post-exit: print session info
|
||||||
|
(println (str "\nSession: " sid))
|
||||||
|
(println (str "To continue: ./agent --session " sid))))
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
(ns agent.core
|
||||||
|
(:require [agent.web-search :as web-search]
|
||||||
|
[babashka.fs :as fs]
|
||||||
|
[babashka.http-client :as http]
|
||||||
|
[cheshire.core :as json]
|
||||||
|
[clojure.java.io :as io]
|
||||||
|
[clojure.string :as str]
|
||||||
|
[clojure.set :as set])
|
||||||
|
(:import [java.time Instant LocalDateTime]
|
||||||
|
[java.time.format DateTimeFormatter]
|
||||||
|
[java.util.regex Pattern]))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Configuration
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(def ollama-host (or (System/getenv "OLLAMA_HOST") "http://localhost:11434"))
|
||||||
|
(def model (or (System/getenv "AGENT_MODEL") "qwen3-coder-next"))
|
||||||
|
(def max-tokens 65536)
|
||||||
|
|
||||||
|
(def system-prompt
|
||||||
|
"You are a helpful coding assistant. You can read, list, create, edit, search, and find files to help the user with their coding tasks.
|
||||||
|
|
||||||
|
When researching or exploring code:
|
||||||
|
1. Use glob_files to find files by pattern
|
||||||
|
2. Use grep_files to search file contents and find relevant line numbers
|
||||||
|
3. Use read_file with offset and limit to read only the relevant sections — avoid reading entire files unless they are small or you need the full context
|
||||||
|
|
||||||
|
For web information:
|
||||||
|
- Use delegate with the web_search agent. It can search the web AND fetch full page content for you.
|
||||||
|
- Describe what you need clearly and the agent will return a concise summary of its findings.
|
||||||
|
- You do NOT have direct web search or URL fetch tools — always delegate.
|
||||||
|
|
||||||
|
Always explain what you're doing before using tools. Use the tools when needed to complete the task.")
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Logging
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(def log-dir
|
||||||
|
(let [home (System/getProperty "user.home")]
|
||||||
|
(.getPath (io/file home ".local" "share" "agent0" "logs"))))
|
||||||
|
|
||||||
|
(defn init-log []
|
||||||
|
(let [ts (.format (LocalDateTime/now) (DateTimeFormatter/ofPattern "yyyy-MM-dd_HH-mm-ss"))
|
||||||
|
f (io/file log-dir (str "agent-" ts ".log"))]
|
||||||
|
(.mkdirs (io/file log-dir))
|
||||||
|
(spit f "")
|
||||||
|
f))
|
||||||
|
|
||||||
|
(defn log [log-file & args]
|
||||||
|
(when log-file
|
||||||
|
(let [ts (.format (LocalDateTime/now) (DateTimeFormatter/ofPattern "HH:mm:ss.SSS"))
|
||||||
|
line (str "[" ts "] " (str/join " " args) "\n")]
|
||||||
|
(spit log-file line :append true))))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Session Persistence
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(def session-dir
|
||||||
|
(let [home (System/getProperty "user.home")]
|
||||||
|
(.getPath (io/file home ".local" "share" "agent0" "sessions"))))
|
||||||
|
|
||||||
|
(defn new-session-id []
|
||||||
|
(str (java.util.UUID/randomUUID)))
|
||||||
|
|
||||||
|
(defn save-session! [session-id data]
|
||||||
|
(let [dir (io/file session-dir)]
|
||||||
|
(.mkdirs dir)
|
||||||
|
(let [f (io/file dir (str session-id ".edn"))
|
||||||
|
session (merge {:id session-id
|
||||||
|
:updated (str (Instant/now))
|
||||||
|
:model model}
|
||||||
|
(when-not (:created data)
|
||||||
|
{:created (str (Instant/now))})
|
||||||
|
data)]
|
||||||
|
(spit f (pr-str session)))))
|
||||||
|
|
||||||
|
(defn load-session [session-id]
|
||||||
|
(let [f (io/file session-dir (str session-id ".edn"))]
|
||||||
|
(when (.exists f)
|
||||||
|
(read-string (slurp f)))))
|
||||||
|
|
||||||
|
(defn latest-session-id []
|
||||||
|
(let [dir (io/file session-dir)]
|
||||||
|
(when (.isDirectory dir)
|
||||||
|
(let [files (->> (.listFiles dir)
|
||||||
|
(filter #(str/ends-with? (.getName %) ".edn"))
|
||||||
|
(sort-by #(.lastModified %) >))]
|
||||||
|
(when (seq files)
|
||||||
|
(str/replace (.getName (first files)) ".edn" ""))))))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Tools Implementation
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(defn read-file [{:keys [path offset limit]}]
|
||||||
|
(let [file (io/file path)]
|
||||||
|
(cond
|
||||||
|
(not (.exists file))
|
||||||
|
(str "Error: File not found: " path)
|
||||||
|
|
||||||
|
(.isDirectory file)
|
||||||
|
(str "Error: Path is a directory, not a file: " path ". Use list_files to view directory contents.")
|
||||||
|
|
||||||
|
:else
|
||||||
|
(let [lines (str/split-lines (slurp file))
|
||||||
|
total (count lines)
|
||||||
|
start (max 0 (dec (or offset 1)))
|
||||||
|
selected (cond->> (drop start lines)
|
||||||
|
limit (take limit))
|
||||||
|
numbered (map-indexed
|
||||||
|
(fn [i line] (str (+ start i 1) "\t" line))
|
||||||
|
selected)]
|
||||||
|
(str (str/join "\n" numbered)
|
||||||
|
"\n[" total " lines total]")))))
|
||||||
|
|
||||||
|
(defn- format-size [size]
|
||||||
|
(cond
|
||||||
|
(< size 1024) (str size "B")
|
||||||
|
(< size (* 1024 1024)) (format "%.1fK" (/ size 1024.0))
|
||||||
|
:else (format "%.1fM" (/ size (* 1024.0 1024.0)))))
|
||||||
|
|
||||||
|
(defn list-files [{:keys [path]}]
|
||||||
|
(let [dir (io/file (or path "."))]
|
||||||
|
(if (.isDirectory dir)
|
||||||
|
(->> (.listFiles dir)
|
||||||
|
(map (fn [f]
|
||||||
|
(cond
|
||||||
|
(.isDirectory f) (str (.getName f) "/")
|
||||||
|
(.canExecute f) (str (.getName f) " [executable, " (format-size (.length f)) "]")
|
||||||
|
:else (str (.getName f) " [" (format-size (.length f)) "]"))))
|
||||||
|
(sort)
|
||||||
|
(str/join "\n"))
|
||||||
|
(str "Error: Not a directory: " path))))
|
||||||
|
|
||||||
|
(defn edit-file [{: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 [{: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)))))
|
||||||
|
|
||||||
|
(defn run-shell-command [{:keys [command]}]
|
||||||
|
(let [proc (-> (ProcessBuilder. ["bash" "-c" command])
|
||||||
|
(.redirectErrorStream true)
|
||||||
|
.start)
|
||||||
|
output (slurp (.getInputStream proc))
|
||||||
|
exit-code (.waitFor proc)]
|
||||||
|
(if (zero? exit-code)
|
||||||
|
output
|
||||||
|
(str output "\n[exit code: " exit-code "]"))))
|
||||||
|
|
||||||
|
(def ^:private default-ignore-dirs
|
||||||
|
#{".git" "node_modules" ".hg" ".svn" "__pycache__" ".cache" "target" ".clj-kondo" ".lsp"})
|
||||||
|
|
||||||
|
(defn- ignored-path? [base-dir path]
|
||||||
|
(let [relative (fs/relativize base-dir path)]
|
||||||
|
(some #(contains? default-ignore-dirs (str %))
|
||||||
|
(iterator-seq (.iterator relative)))))
|
||||||
|
|
||||||
|
(defn glob-files [{:keys [pattern path]}]
|
||||||
|
(let [base-dir (or path ".")
|
||||||
|
results (->> (fs/glob base-dir pattern)
|
||||||
|
(remove #(ignored-path? base-dir %))
|
||||||
|
(map #(str (fs/relativize base-dir %)))
|
||||||
|
sort)]
|
||||||
|
(if (seq results)
|
||||||
|
(str/join "\n" results)
|
||||||
|
"No files matched.")))
|
||||||
|
|
||||||
|
(defn grep-files [{:keys [pattern path include]}]
|
||||||
|
(let [base-dir (or path ".")
|
||||||
|
compiled (Pattern/compile pattern)
|
||||||
|
max-matches 100
|
||||||
|
files (->> (fs/glob base-dir (or include "**"))
|
||||||
|
(filter #(and (fs/regular-file? %)
|
||||||
|
(not (ignored-path? base-dir %))
|
||||||
|
(<= (fs/size %) (* 1024 1024)))))
|
||||||
|
results (reduce
|
||||||
|
(fn [acc file]
|
||||||
|
(if (>= (count acc) max-matches)
|
||||||
|
(reduced acc)
|
||||||
|
(try
|
||||||
|
(let [relative (str (fs/relativize base-dir file))
|
||||||
|
lines (str/split-lines (slurp (fs/file file)))]
|
||||||
|
(reduce
|
||||||
|
(fn [acc2 [idx line]]
|
||||||
|
(if (>= (count acc2) max-matches)
|
||||||
|
(reduced acc2)
|
||||||
|
(if (.find (.matcher compiled line))
|
||||||
|
(conj acc2 (str relative ":" (inc idx) ":" line))
|
||||||
|
acc2)))
|
||||||
|
acc
|
||||||
|
(map-indexed vector lines)))
|
||||||
|
(catch Exception _ acc))))
|
||||||
|
[]
|
||||||
|
files)
|
||||||
|
truncated? (>= (count results) max-matches)]
|
||||||
|
(if (seq results)
|
||||||
|
(cond-> (str/join "\n" results)
|
||||||
|
truncated? (str "\n[truncated at " max-matches " matches]"))
|
||||||
|
"No matches found.")))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Ollama API (parameterized)
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(defn- call-llm* [sys-prompt tool-defs messages]
|
||||||
|
(let [body {:model model
|
||||||
|
:options {:num_predict max-tokens}
|
||||||
|
:messages (into [{:role "system" :content sys-prompt}] messages)
|
||||||
|
:tools tool-defs
|
||||||
|
:stream false}
|
||||||
|
response (http/post (str ollama-host "/api/chat")
|
||||||
|
{:headers {"Content-Type" "application/json"}
|
||||||
|
:body (json/generate-string body)})]
|
||||||
|
(json/parse-string (:body response) true)))
|
||||||
|
|
||||||
|
(defn- truncate [s max-len]
|
||||||
|
(if (> (count s) max-len)
|
||||||
|
(str (subs s 0 max-len) "... [" (count s) " chars]")
|
||||||
|
s))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Subagent Infrastructure
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(def ^:dynamic *log-file*
|
||||||
|
"Dynamic var for passing log-file context to tool functions (e.g. delegate)."
|
||||||
|
nil)
|
||||||
|
|
||||||
|
(def subagent-registry
|
||||||
|
"Map of agent name -> subagent config."
|
||||||
|
{"web_search" web-search/subagent-config})
|
||||||
|
|
||||||
|
(defn run-subagent!
|
||||||
|
"Run a subagent synchronously. Returns the final text response.
|
||||||
|
Config keys: :system-prompt, :tools (with :impl), :max-iterations."
|
||||||
|
[{:keys [system-prompt tools max-iterations]} task log-file]
|
||||||
|
(let [max-iter (or max-iterations 10)
|
||||||
|
tool-defs (mapv #(dissoc % :impl) tools)
|
||||||
|
tmap (into {} (map (fn [t] [(get-in t [:function :name]) (:impl t)]) tools))]
|
||||||
|
(log log-file " [subagent] started | task:" (truncate task 100))
|
||||||
|
(loop [messages [{:role "user" :content task}]
|
||||||
|
iteration 0
|
||||||
|
texts []]
|
||||||
|
(if (>= iteration max-iter)
|
||||||
|
(do (log log-file " [subagent] max iterations reached")
|
||||||
|
(str/join "\n\n" texts))
|
||||||
|
(let [result (call-llm* system-prompt tool-defs messages)
|
||||||
|
message (:message result)
|
||||||
|
content (:content message)
|
||||||
|
tool-calls (:tool_calls message)
|
||||||
|
texts (if (and content (seq (str/trim content)))
|
||||||
|
(conj texts content)
|
||||||
|
texts)]
|
||||||
|
(if (seq tool-calls)
|
||||||
|
;; Execute subagent tools and continue
|
||||||
|
(let [tool-results
|
||||||
|
(mapv (fn [tc]
|
||||||
|
(let [tool-name (get-in tc [:function :name])
|
||||||
|
raw-args (get-in tc [:function :arguments])
|
||||||
|
args (if (string? raw-args)
|
||||||
|
(json/parse-string raw-args true)
|
||||||
|
raw-args)
|
||||||
|
tool-fn (get tmap tool-name)]
|
||||||
|
(log log-file " [subagent] tool:" tool-name (pr-str args))
|
||||||
|
(let [r (try
|
||||||
|
(if tool-fn
|
||||||
|
(tool-fn args)
|
||||||
|
(str "Error: Unknown tool '" tool-name "'"))
|
||||||
|
(catch Exception e
|
||||||
|
(str "Error: " (.getMessage e))))]
|
||||||
|
(log log-file " [subagent] result:" (truncate (str/replace (str r) #"\n" "\\n") 200))
|
||||||
|
{:role "tool" :tool_call_id (:id tc) :content (str r)})))
|
||||||
|
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) texts))
|
||||||
|
;; Done
|
||||||
|
(do (log log-file " [subagent] finished after" iteration "iterations")
|
||||||
|
(str/join "\n\n" texts))))))))
|
||||||
|
|
||||||
|
(defn delegate [{:keys [agent task]}]
|
||||||
|
(let [config (get subagent-registry agent)]
|
||||||
|
(if config
|
||||||
|
(try
|
||||||
|
(run-subagent! config task *log-file*)
|
||||||
|
(catch Exception e
|
||||||
|
(str "Error running " agent " agent: " (.getMessage e))))
|
||||||
|
(str "Error: Unknown agent '" agent "'. Available agents: "
|
||||||
|
(str/join ", " (keys subagent-registry))))))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Tool Registry (OpenAI format)
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(def tools
|
||||||
|
[{:type "function"
|
||||||
|
:function {:name "read_file"
|
||||||
|
:description "Read the contents of a file. Returns numbered lines with format 'LINE_NUM\\tCONTENT'. Without offset/limit, reads the ENTIRE file. For research or exploration, prefer reading specific sections using offset and limit rather than the whole file — use grep_files first to find relevant line numbers, then read just those sections."
|
||||||
|
:parameters {:type "object"
|
||||||
|
:properties {:path {:type "string" :description "The relative path to the file to read"}
|
||||||
|
:offset {:type "integer" :description "Starting line number (1-based, default: 1). Use with limit to read a specific range."}
|
||||||
|
:limit {:type "integer" :description "Max lines to return from offset. E.g. offset=10, limit=20 reads lines 10-29."}}
|
||||||
|
:required ["path"]}}
|
||||||
|
:impl read-file}
|
||||||
|
|
||||||
|
{:type "function"
|
||||||
|
:function {:name "list_files"
|
||||||
|
:description "List files and directories at a given path."
|
||||||
|
: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."
|
||||||
|
:parameters {:type "object"
|
||||||
|
:properties {:path {:type "string" :description "The path to the file to edit"}
|
||||||
|
:old_str {:type "string" :description "The exact text to replace (must be unique)"}
|
||||||
|
: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 path for the new file"}
|
||||||
|
:content {:type "string" :description "The content to write"}}
|
||||||
|
:required ["path" "content"]}}
|
||||||
|
:impl create-file}
|
||||||
|
|
||||||
|
{:type "function"
|
||||||
|
:function {:name "run_shell_command"
|
||||||
|
:description "Run a shell command and return its stdout/stderr output."
|
||||||
|
:parameters {:type "object"
|
||||||
|
:properties {:command {:type "string" :description "The shell command to execute"}}
|
||||||
|
:required ["command"]}}
|
||||||
|
:impl run-shell-command}
|
||||||
|
|
||||||
|
{:type "function"
|
||||||
|
:function {:name "glob_files"
|
||||||
|
:description "Find files matching a glob pattern (e.g. \"**/*.clj\", \"src/**/*.ts\"). Searches recursively from the given path."
|
||||||
|
:parameters {:type "object"
|
||||||
|
:properties {:pattern {:type "string" :description "Glob pattern to match (e.g. \"**/*.clj\", \"*.md\", \"src/**/*.ts\")"}
|
||||||
|
:path {:type "string" :description "Directory to search from (defaults to current directory)"}}
|
||||||
|
:required ["pattern"]}}
|
||||||
|
:impl glob-files}
|
||||||
|
|
||||||
|
{:type "function"
|
||||||
|
:function {:name "grep_files"
|
||||||
|
:description "Search file contents for a regex pattern. Returns matching lines with file path and line number. Searches recursively from the given path."
|
||||||
|
:parameters {:type "object"
|
||||||
|
:properties {:pattern {:type "string" :description "Regex pattern to search for in file contents"}
|
||||||
|
:path {:type "string" :description "Directory to search from (defaults to current directory)"}
|
||||||
|
:include {:type "string" :description "Glob pattern to filter which files to search (e.g. \"**/*.clj\")"}}
|
||||||
|
:required ["pattern"]}}
|
||||||
|
:impl grep-files}
|
||||||
|
|
||||||
|
{:type "function"
|
||||||
|
:function {:name "delegate"
|
||||||
|
:description "Delegate a task to a specialized subagent. The subagent runs autonomously with its own tools and returns a comprehensive result. Available agents: web_search (in-depth web research with multiple searches and synthesis)."
|
||||||
|
:parameters {:type "object"
|
||||||
|
:properties {:agent {:type "string" :description "The subagent to delegate to (e.g. \"web_search\")"}
|
||||||
|
:task {:type "string" :description "A clear description of what you want the subagent to research or do"}}
|
||||||
|
:required ["agent" "task"]}}
|
||||||
|
:impl delegate}])
|
||||||
|
|
||||||
|
(def tool-map
|
||||||
|
(into {} (map (fn [t] [(get-in t [:function :name]) (:impl t)]) tools)))
|
||||||
|
|
||||||
|
(def tool-definitions
|
||||||
|
(mapv #(dissoc % :impl) tools))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Main Agent LLM
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(defn call-llm [messages]
|
||||||
|
(let [result (call-llm* system-prompt tool-definitions messages)]
|
||||||
|
{:choices [{:message (:message result)
|
||||||
|
:finish_reason (if (seq (get-in result [:message :tool_calls]))
|
||||||
|
"tool_calls"
|
||||||
|
"stop")}]}))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Tool Execution
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(defn execute-tool [log-file {:keys [id function]}]
|
||||||
|
(let [tool-name (:name function)
|
||||||
|
raw-args (:arguments function)
|
||||||
|
args (if (string? raw-args) (json/parse-string raw-args true) raw-args)]
|
||||||
|
(log log-file "-> Tool:" tool-name (pr-str args))
|
||||||
|
(let [tool-fn (get tool-map tool-name)
|
||||||
|
result (try
|
||||||
|
(if tool-fn
|
||||||
|
(binding [*log-file* log-file]
|
||||||
|
(tool-fn args))
|
||||||
|
(str "Error: Unknown tool '" tool-name "'"))
|
||||||
|
(catch Exception e
|
||||||
|
(str "Error: " (.getMessage e))))]
|
||||||
|
(log log-file "<- Result:" (truncate (str/replace (str result) #"\n" "\\n") 200))
|
||||||
|
{:role "tool" :tool_call_id id :content result})))
|
||||||
|
|
||||||
|
(defn tool-call-label
|
||||||
|
"Generate a human-readable label for a tool call."
|
||||||
|
[tc]
|
||||||
|
(let [tname (get-in tc [:function :name])
|
||||||
|
raw-args (get-in tc [:function :arguments])
|
||||||
|
args (if (string? raw-args) (json/parse-string raw-args true) raw-args)]
|
||||||
|
(case tname
|
||||||
|
"read_file" (str "Reading " (:path args)
|
||||||
|
(if (or (:offset args) (:limit args))
|
||||||
|
(str ":" (or (:offset args) 1)
|
||||||
|
(when (:limit args) (str "-" (+ (dec (or (:offset args) 1)) (:limit args)))))
|
||||||
|
" (entire file)"))
|
||||||
|
"list_files" (str "Listing " (or (:path args) "."))
|
||||||
|
"edit_file" (str "Editing " (:path args))
|
||||||
|
"create_file" (str "Creating " (:path args))
|
||||||
|
"run_shell_command" (str "Running `" (:command args) "`")
|
||||||
|
"glob_files" (str "Globbing " (:pattern args) (when (:path args) (str " in " (:path args))))
|
||||||
|
"grep_files" (str "Grepping " (:pattern args) (when (:include args) (str " in " (:include args))))
|
||||||
|
"delegate" (str "Spawning " (:agent args) " subagent")
|
||||||
|
(str "Calling " tname))))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Stuck Loop Detection
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(defn- tool-call-signature [{:keys [function]}]
|
||||||
|
[(:name function) (:arguments function)])
|
||||||
|
|
||||||
|
(defn- tool-call-names [tool-calls]
|
||||||
|
(mapv #(get-in % [:function :name]) tool-calls))
|
||||||
|
|
||||||
|
(defn- detect-stuck-loop
|
||||||
|
"Detects two kinds of stuck loops:
|
||||||
|
1. Exact repeat: identical tool calls N times in a row (hard stop)
|
||||||
|
2. Research loop: same tool(s) called N+ times with varying args (nudge, then stop)
|
||||||
|
Returns {:signatures, :stuck? (tool name or nil), :nudge? bool}"
|
||||||
|
[tool-calls previous-signatures repeat-threshold]
|
||||||
|
(let [current-sigs (mapv tool-call-signature tool-calls)
|
||||||
|
all-sigs (conj previous-signatures current-sigs)
|
||||||
|
;; Exact repeat detection (existing behavior)
|
||||||
|
exact-stuck? (when (>= (count all-sigs) repeat-threshold)
|
||||||
|
(let [recent (take-last repeat-threshold all-sigs)]
|
||||||
|
(when (apply = recent)
|
||||||
|
(ffirst (last recent)))))
|
||||||
|
;; Research loop detection: same tool name(s) used N consecutive times
|
||||||
|
;; with different arguments (e.g. varied web_search queries)
|
||||||
|
names-history (mapv (fn [sigs] (set (map first sigs))) all-sigs)
|
||||||
|
research-tools #{"delegate"}
|
||||||
|
consecutive-research
|
||||||
|
(count (take-while #(and (seq (set/intersection % research-tools))
|
||||||
|
(every? research-tools %))
|
||||||
|
(reverse names-history)))
|
||||||
|
nudge-threshold 3
|
||||||
|
hard-research-limit 6]
|
||||||
|
{:signatures all-sigs
|
||||||
|
:stuck? (or exact-stuck?
|
||||||
|
(when (>= consecutive-research hard-research-limit)
|
||||||
|
"web_search"))
|
||||||
|
:nudge? (and (not exact-stuck?)
|
||||||
|
(= consecutive-research nudge-threshold))}))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Async Agent Loop
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(defn run-agent-loop!
|
||||||
|
"Run agent loop in a background thread.
|
||||||
|
Pushes events to event-queue atom:
|
||||||
|
{:type :text :content \"...\"} - assistant text
|
||||||
|
{:type :tool :label \"...\"} - tool being called
|
||||||
|
{:type :error :message \"...\"} - error
|
||||||
|
{:type :done :conversation [...]} - loop finished
|
||||||
|
Returns {:future f :cancel! cancel-atom}."
|
||||||
|
[conversation event-queue]
|
||||||
|
(let [log-file (init-log)
|
||||||
|
cancelled? (atom false)]
|
||||||
|
(log log-file "Agent loop started | model:" model "| messages:" (count conversation))
|
||||||
|
{:cancel! cancelled?
|
||||||
|
:future
|
||||||
|
(future
|
||||||
|
(try
|
||||||
|
(loop [messages conversation
|
||||||
|
iteration 0
|
||||||
|
tool-sigs []]
|
||||||
|
(cond
|
||||||
|
@cancelled?
|
||||||
|
(do
|
||||||
|
(log log-file "Agent interrupted by user")
|
||||||
|
(swap! event-queue conj {:type :error :message "Interrupted."})
|
||||||
|
(swap! event-queue conj {:type :done :conversation messages}))
|
||||||
|
|
||||||
|
(> iteration 50)
|
||||||
|
(do
|
||||||
|
(log log-file "Max iterations (50) reached")
|
||||||
|
(swap! event-queue conj {:type :error :message "Max iterations reached."})
|
||||||
|
(swap! event-queue conj {:type :done :conversation messages}))
|
||||||
|
|
||||||
|
:else
|
||||||
|
(do
|
||||||
|
(log log-file "Iteration" iteration "| messages:" (count messages))
|
||||||
|
(let [response (call-llm messages)
|
||||||
|
choice (first (:choices response))
|
||||||
|
message (:message choice)
|
||||||
|
finish-reason (:finish_reason choice)
|
||||||
|
content (:content message)
|
||||||
|
tool-calls (:tool_calls message)]
|
||||||
|
(log log-file "finish_reason:" finish-reason "| tool_calls:" (count (or tool-calls [])))
|
||||||
|
;; Push assistant text
|
||||||
|
(when (and content (seq (str/trim content)))
|
||||||
|
(swap! event-queue conj {:type :text :content content}))
|
||||||
|
;; Handle tool calls or finish
|
||||||
|
(if (and (= finish-reason "tool_calls") (seq tool-calls))
|
||||||
|
(let [{:keys [signatures stuck? nudge?]} (detect-stuck-loop tool-calls tool-sigs 3)]
|
||||||
|
(cond
|
||||||
|
stuck?
|
||||||
|
(do
|
||||||
|
(log log-file "Stuck loop detected:" stuck?)
|
||||||
|
(swap! event-queue conj {:type :error :message "Agent is repeating the same action."})
|
||||||
|
(swap! event-queue conj {:type :done :conversation messages}))
|
||||||
|
|
||||||
|
:else
|
||||||
|
(do
|
||||||
|
;; Show tool activities
|
||||||
|
(doseq [tc tool-calls]
|
||||||
|
(swap! event-queue conj {:type :tool :label (tool-call-label tc)}))
|
||||||
|
;; Execute tools
|
||||||
|
(let [tool-results (mapv #(execute-tool log-file %) tool-calls)
|
||||||
|
assistant-msg (select-keys message [:role :content :tool_calls])
|
||||||
|
new-messages (into (conj messages assistant-msg) tool-results)
|
||||||
|
;; If nudge, inject a system hint to stop researching
|
||||||
|
new-messages (if nudge?
|
||||||
|
(do (log log-file "Research loop nudge injected")
|
||||||
|
(conj new-messages
|
||||||
|
{:role "system"
|
||||||
|
:content "You have already performed several web searches. You have enough information to answer. Stop searching and synthesize your findings into a clear response now."}))
|
||||||
|
new-messages)]
|
||||||
|
(recur new-messages (inc iteration) signatures)))))
|
||||||
|
;; Done - no more tool calls
|
||||||
|
(do
|
||||||
|
(log log-file "Agent finished after" iteration "iterations")
|
||||||
|
(swap! event-queue conj
|
||||||
|
{:type :done
|
||||||
|
:conversation (conj messages
|
||||||
|
(select-keys message [:role :content]))})))))))
|
||||||
|
(catch Exception e
|
||||||
|
(log log-file "Agent error:" (str e))
|
||||||
|
(swap! event-queue conj {:type :error :message (str e)})
|
||||||
|
(swap! event-queue conj {:type :done :conversation conversation}))))}))
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
(ns agent.markdown
|
||||||
|
"Convert markdown text to ANSI-styled terminal output."
|
||||||
|
(:require [clojure.string :as str]
|
||||||
|
[tui.ansi :as ansi]))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Inline Formatting
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(defn- protect-code-spans
|
||||||
|
"Replace inline code spans with null-byte placeholders.
|
||||||
|
Returns [modified-text, codes-vector]."
|
||||||
|
[text]
|
||||||
|
(let [codes (volatile! [])
|
||||||
|
modified (str/replace text #"`([^`]+)`"
|
||||||
|
(fn [[_ code]]
|
||||||
|
(let [idx (count @codes)]
|
||||||
|
(vswap! codes conj code)
|
||||||
|
(str "\u0000C" idx "\u0000"))))]
|
||||||
|
[modified @codes]))
|
||||||
|
|
||||||
|
(defn- restore-code-spans
|
||||||
|
"Replace placeholders with ANSI-styled inline code."
|
||||||
|
[text codes]
|
||||||
|
(str/replace text #"\u0000C(\d+)\u0000"
|
||||||
|
(fn [[_ idx-str]]
|
||||||
|
(ansi/style (nth codes (parse-long idx-str)) :fg :yellow))))
|
||||||
|
|
||||||
|
(defn- apply-inline
|
||||||
|
"Convert inline markdown syntax to ANSI escape codes."
|
||||||
|
[text]
|
||||||
|
(let [[protected codes] (protect-code-spans text)
|
||||||
|
styled (-> protected
|
||||||
|
;; Bold + italic: ***text***
|
||||||
|
(str/replace #"\*\*\*(.+?)\*\*\*"
|
||||||
|
(fn [[_ t]] (ansi/style t :bold true :italic true)))
|
||||||
|
;; Bold: **text**
|
||||||
|
(str/replace #"\*\*(.+?)\*\*"
|
||||||
|
(fn [[_ t]] (ansi/style t :bold true)))
|
||||||
|
;; Italic: *text* (not inside words, not after/before *)
|
||||||
|
(str/replace #"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)"
|
||||||
|
(fn [[_ t]] (ansi/style t :italic true)))
|
||||||
|
;; Strikethrough: ~~text~~
|
||||||
|
(str/replace #"~~(.+?)~~"
|
||||||
|
(fn [[_ t]] (ansi/style t :strike true)))
|
||||||
|
;; Links: [text](url) → text (url dimmed)
|
||||||
|
(str/replace #"\[([^\]]+)\]\(([^)]+)\)"
|
||||||
|
(fn [[_ text url]]
|
||||||
|
(str (ansi/style text :underline true)
|
||||||
|
(ansi/style (str " " url) :dim true)))))]
|
||||||
|
(restore-code-spans styled codes)))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; ANSI-aware Word Wrapping
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(defn- parse-active-styles
|
||||||
|
"Determine which ANSI SGR codes are active at the end of a string.
|
||||||
|
Returns the SGR escape sequence to re-apply those styles, or empty string."
|
||||||
|
[s]
|
||||||
|
(let [matches (re-seq #"\u001b\[([0-9;]*)m" s)]
|
||||||
|
(if (empty? matches)
|
||||||
|
""
|
||||||
|
(let [active (reduce
|
||||||
|
(fn [acc [_ codes-str]]
|
||||||
|
(reduce
|
||||||
|
(fn [a code]
|
||||||
|
(if (= code "0")
|
||||||
|
#{}
|
||||||
|
(if (seq code) (conj a code) a)))
|
||||||
|
acc
|
||||||
|
(str/split codes-str #";")))
|
||||||
|
#{}
|
||||||
|
matches)]
|
||||||
|
(if (empty? active)
|
||||||
|
""
|
||||||
|
(str ansi/csi (str/join ";" (sort active)) "m"))))))
|
||||||
|
|
||||||
|
(defn- wrap-words
|
||||||
|
"Word-wrap text that may contain ANSI codes. Returns vector of lines.
|
||||||
|
Carries ANSI style state across line breaks for correct rendering."
|
||||||
|
[text max-width]
|
||||||
|
(let [words (str/split text #" ")
|
||||||
|
;; Phase 1: naive wrap by visible width
|
||||||
|
raw-lines
|
||||||
|
(loop [[word & remaining] words
|
||||||
|
current ""
|
||||||
|
result []]
|
||||||
|
(if-not word
|
||||||
|
(if (empty? current) result (conj result current))
|
||||||
|
(if (empty? current)
|
||||||
|
(recur remaining word result)
|
||||||
|
(let [new-line (str current " " word)]
|
||||||
|
(if (> (ansi/visible-length new-line) max-width)
|
||||||
|
(recur (cons word remaining) "" (conj result current))
|
||||||
|
(recur remaining new-line result))))))
|
||||||
|
;; Phase 2: carry styles across line breaks
|
||||||
|
final-lines
|
||||||
|
(loop [[line & remaining] raw-lines
|
||||||
|
carry ""
|
||||||
|
result []]
|
||||||
|
(if-not line
|
||||||
|
result
|
||||||
|
(let [full-line (str carry line)
|
||||||
|
styles (parse-active-styles full-line)]
|
||||||
|
(recur remaining
|
||||||
|
styles
|
||||||
|
(conj result (str full-line ansi/reset))))))]
|
||||||
|
final-lines))
|
||||||
|
|
||||||
|
(defn- wrap-styled-text
|
||||||
|
"Word-wrap ANSI-styled text, respecting existing newlines."
|
||||||
|
[text max-width]
|
||||||
|
(if (or (nil? text) (str/blank? text))
|
||||||
|
[""]
|
||||||
|
(vec (mapcat #(if (str/blank? %) [""] (wrap-words % max-width))
|
||||||
|
(str/split-lines text)))))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Block-level Markdown Parsing
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(defn render-markdown
|
||||||
|
"Convert markdown text to a vector of ANSI-styled, word-wrapped lines."
|
||||||
|
[text width]
|
||||||
|
(if (or (nil? text) (empty? text))
|
||||||
|
[""]
|
||||||
|
(loop [[line & remaining] (str/split-lines text)
|
||||||
|
in-code-block? false
|
||||||
|
result []]
|
||||||
|
(if-not line
|
||||||
|
result
|
||||||
|
(cond
|
||||||
|
;; Code fence toggle
|
||||||
|
(str/starts-with? (str/trimr line) "```")
|
||||||
|
(if in-code-block?
|
||||||
|
(recur remaining false result)
|
||||||
|
(recur remaining true result))
|
||||||
|
|
||||||
|
;; Inside code block — preserve formatting, no wrapping
|
||||||
|
in-code-block?
|
||||||
|
(recur remaining true
|
||||||
|
(conj result (ansi/style (str " " line) :fg :bright-black)))
|
||||||
|
|
||||||
|
;; Header: # text
|
||||||
|
(re-matches #"^(#{1,6})\s+(.*)" line)
|
||||||
|
(let [[_ hashes text] (re-matches #"^(#{1,6})\s+(.*)" line)
|
||||||
|
level (count hashes)
|
||||||
|
color (case level 1 :cyan 2 :green 3 :yellow :white)
|
||||||
|
styled (ansi/style (apply-inline text) :bold true :fg color)]
|
||||||
|
(recur remaining false
|
||||||
|
(into result (wrap-styled-text styled width))))
|
||||||
|
|
||||||
|
;; Horizontal rule
|
||||||
|
(re-matches #"^[-*_]{3,}\s*$" line)
|
||||||
|
(recur remaining false
|
||||||
|
(conj result (ansi/style
|
||||||
|
(apply str (repeat (min width 50) "─"))
|
||||||
|
:dim true)))
|
||||||
|
|
||||||
|
;; Blockquote: > text
|
||||||
|
(str/starts-with? line "> ")
|
||||||
|
(let [text (subs line 2)
|
||||||
|
bar (ansi/style "│ " :fg :cyan)
|
||||||
|
styled (str bar (ansi/style (apply-inline text) :dim true))]
|
||||||
|
(recur remaining false
|
||||||
|
(into result (wrap-styled-text styled (- width 2)))))
|
||||||
|
|
||||||
|
;; Unordered list item: - text, * text, + text
|
||||||
|
(re-matches #"^(\s*)[-*+]\s+(.*)" line)
|
||||||
|
(let [[_ indent text] (re-matches #"^(\s*)[-*+]\s+(.*)" line)
|
||||||
|
prefix (str indent " • ")
|
||||||
|
styled (str prefix (apply-inline text))]
|
||||||
|
(recur remaining false
|
||||||
|
(into result (wrap-styled-text styled width))))
|
||||||
|
|
||||||
|
;; Ordered list item: 1. text
|
||||||
|
(re-matches #"^(\s*)(\d+\.)\s+(.*)" line)
|
||||||
|
(let [[_ indent num text] (re-matches #"^(\s*)(\d+\.)\s+(.*)" line)
|
||||||
|
prefix (str indent " " num " ")
|
||||||
|
styled (str prefix (apply-inline text))]
|
||||||
|
(recur remaining false
|
||||||
|
(into result (wrap-styled-text styled width))))
|
||||||
|
|
||||||
|
;; Blank line
|
||||||
|
(str/blank? line)
|
||||||
|
(recur remaining false (conj result ""))
|
||||||
|
|
||||||
|
;; Regular paragraph — apply inline formatting and wrap
|
||||||
|
:else
|
||||||
|
(recur remaining false
|
||||||
|
(into result (wrap-styled-text (apply-inline line) width))))))))
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
(ns agent.web-search
|
||||||
|
"Web search tool, URL fetching, and subagent configuration."
|
||||||
|
(:require [babashka.http-client :as http]
|
||||||
|
[clojure.string :as str])
|
||||||
|
(:import [java.net URLDecoder]))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Web Search Implementation
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(defn- html-unescape [s]
|
||||||
|
(-> s
|
||||||
|
(str/replace "&" "&")
|
||||||
|
(str/replace "<" "<")
|
||||||
|
(str/replace ">" ">")
|
||||||
|
(str/replace """ "\"")
|
||||||
|
(str/replace "'" "'")
|
||||||
|
(str/replace "'" "'")
|
||||||
|
(str/replace " " " ")))
|
||||||
|
|
||||||
|
(defn- strip-tags [s]
|
||||||
|
(-> s
|
||||||
|
(str/replace #"<[^>]+>" "")
|
||||||
|
str/trim
|
||||||
|
html-unescape))
|
||||||
|
|
||||||
|
(defn- extract-ddg-url [href]
|
||||||
|
(if-let [[_ encoded] (re-find #"uddg=([^&]+)" href)]
|
||||||
|
(URLDecoder/decode encoded "UTF-8")
|
||||||
|
href))
|
||||||
|
|
||||||
|
(defn web-search [{:keys [query num_results]}]
|
||||||
|
(let [n (min (or num_results 5) 10)
|
||||||
|
response (http/get "https://html.duckduckgo.com/html/"
|
||||||
|
{:query-params {"q" query}
|
||||||
|
:headers {"User-Agent" "Mozilla/5.0 (compatible; agent0/1.0)"}})
|
||||||
|
body (:body response)
|
||||||
|
titles (->> (re-seq #"(?s)<a[^>]*class=\"result__a\"[^>]*href=\"([^\"]+)\"[^>]*>(.*?)</a>" body)
|
||||||
|
(map (fn [[_ url title]]
|
||||||
|
{:url (extract-ddg-url url)
|
||||||
|
:title (strip-tags title)})))
|
||||||
|
snippets (->> (re-seq #"(?s)<a[^>]*class=\"result__snippet\"[^>]*>(.*?)</a>" body)
|
||||||
|
(map (fn [[_ snippet]] (strip-tags snippet))))
|
||||||
|
combined (->> (map (fn [r s] (assoc r :snippet s)) titles snippets)
|
||||||
|
(take n)
|
||||||
|
(map-indexed (fn [i {:keys [title snippet url]}]
|
||||||
|
(str (inc i) ". " title "\n"
|
||||||
|
" " snippet "\n"
|
||||||
|
" " url))))]
|
||||||
|
(if (seq combined)
|
||||||
|
(str/join "\n\n" combined)
|
||||||
|
"No results found.")))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; URL Fetch Implementation
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(def ^:private max-fetch-chars 8000)
|
||||||
|
|
||||||
|
(defn fetch-url [{:keys [url]}]
|
||||||
|
(try
|
||||||
|
(let [response (http/get url
|
||||||
|
{:headers {"User-Agent" "Mozilla/5.0 (compatible; agent0/1.0)"
|
||||||
|
"Accept" "text/html,text/plain,application/json"}
|
||||||
|
:timeout 10000})
|
||||||
|
body (:body response)
|
||||||
|
;; Strip HTML tags for readable text
|
||||||
|
text (-> body
|
||||||
|
;; Remove script/style blocks entirely
|
||||||
|
(str/replace #"(?si)<(script|style|nav|header|footer)[^>]*>.*?</\1>" "")
|
||||||
|
;; Convert common block elements to newlines
|
||||||
|
(str/replace #"<(br|p|div|h[1-6]|li|tr)[^>]*>" "\n")
|
||||||
|
;; Strip remaining tags
|
||||||
|
(str/replace #"<[^>]+>" "")
|
||||||
|
;; Clean up whitespace
|
||||||
|
(str/replace #"[ \t]+" " ")
|
||||||
|
(str/replace #"\n{3,}" "\n\n")
|
||||||
|
str/trim
|
||||||
|
html-unescape)]
|
||||||
|
(if (> (count text) max-fetch-chars)
|
||||||
|
(str (subs text 0 max-fetch-chars) "\n\n[truncated at " max-fetch-chars " chars, " (count text) " total]")
|
||||||
|
text))
|
||||||
|
(catch Exception e
|
||||||
|
(str "Error fetching URL: " (.getMessage e)))))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Tool Definition
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(def tool
|
||||||
|
{:type "function"
|
||||||
|
:function {:name "web_search"
|
||||||
|
:description "Search the web using DuckDuckGo. Returns titles, snippets, and URLs. Use multiple searches with different queries to gather thorough results."
|
||||||
|
:parameters {:type "object"
|
||||||
|
:properties {:query {:type "string" :description "The search query"}
|
||||||
|
:num_results {:type "integer" :description "Number of results to return (default 5, max 10)"}}
|
||||||
|
:required ["query"]}}
|
||||||
|
:impl web-search})
|
||||||
|
|
||||||
|
(def fetch-tool
|
||||||
|
{:type "function"
|
||||||
|
:function {:name "fetch_url"
|
||||||
|
:description "Fetch and read the text content of a web page. Use this after web_search when you need more detail from a specific result. Returns the page text with HTML stripped. Limited to 8000 chars."
|
||||||
|
:parameters {:type "object"
|
||||||
|
:properties {:url {:type "string" :description "The URL to fetch"}}
|
||||||
|
:required ["url"]}}
|
||||||
|
:impl fetch-url})
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Subagent Configuration
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(def subagent-config
|
||||||
|
{:name "web_search"
|
||||||
|
:description "A web research agent that can perform multiple searches and synthesize findings."
|
||||||
|
:system-prompt
|
||||||
|
"You are a web research agent. Your job is to search the web, fetch pages when needed, and return a concise summary of your findings.
|
||||||
|
|
||||||
|
You have access to web_search and fetch_url. Strategy:
|
||||||
|
1. Search for the topic (1-2 searches usually suffice)
|
||||||
|
2. If search snippets aren't enough, use fetch_url to read the most relevant page
|
||||||
|
3. Synthesize your findings into a SHORT, focused response — only the key facts the caller needs
|
||||||
|
4. Include 1-2 relevant URLs as references
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Do NOT keep searching with rephrased queries. If you found results, use them.
|
||||||
|
- Do NOT dump raw page text. Extract and summarize the important parts.
|
||||||
|
- Aim for 1-3 searches total, use fetch_url only when snippets are insufficient.
|
||||||
|
- Your response goes back to the main agent, so keep it concise and information-dense."
|
||||||
|
:tools [tool fetch-tool]
|
||||||
|
:max-iterations 10})
|
||||||
Reference in New Issue
Block a user