add diff links (for quick edits)
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
logs/
|
logs/
|
||||||
.clj-kondo/
|
.clj-kondo/
|
||||||
.lsp/
|
.lsp/
|
||||||
|
.cpcache/
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{:paths ["src"]
|
||||||
|
:deps {tui/tui {:git/url "https://git.ajet.fyi/ajet/clojure-tui.git"
|
||||||
|
:git/sha "4a051304888d5b4d937262decba919e1a79dd03d"}}}
|
||||||
+103
-81
@@ -132,12 +132,13 @@
|
|||||||
change-start (if has-header?
|
change-start (if has-header?
|
||||||
(or (some-> (re-find #":(\d+)" header) second parse-long) 1)
|
(or (some-> (re-find #":(\d+)" header) second parse-long) 1)
|
||||||
1)
|
1)
|
||||||
;; Extract file extension from @@ header for syntax highlighting
|
;; Extract file path and extension from @@ header
|
||||||
diff-lang (when has-header?
|
diff-path (when has-header?
|
||||||
(when-let [path (second (re-find #"@@\s+(?:new:\s+)?(\S+)" header))]
|
(second (re-find #"@@\s+(?:new:\s+)?(\S+)" header)))
|
||||||
(let [dot-idx (str/last-index-of path ".")]
|
diff-lang (when diff-path
|
||||||
(when dot-idx
|
(let [dot-idx (str/last-index-of diff-path ".")]
|
||||||
(syntax/lang-for-ext (subs path dot-idx))))))
|
(when dot-idx
|
||||||
|
(syntax/lang-for-ext (subs diff-path dot-idx)))))
|
||||||
body-lines (if has-header? (rest raw-lines) raw-lines)
|
body-lines (if has-header? (rest raw-lines) raw-lines)
|
||||||
ctx-before (count (take-while #(str/starts-with? % " ") body-lines))
|
ctx-before (count (take-while #(str/starts-with? % " ") body-lines))
|
||||||
first-lineno (- change-start ctx-before)
|
first-lineno (- change-start ctx-before)
|
||||||
@@ -145,35 +146,42 @@
|
|||||||
rst "\033[0m"
|
rst "\033[0m"
|
||||||
[body-els _]
|
[body-els _]
|
||||||
(reduce
|
(reduce
|
||||||
(fn [[acc {:keys [old-n new-n]}] line]
|
(fn [[acc {:keys [old-n new-n]}] line]
|
||||||
(cond
|
(cond
|
||||||
(str/starts-with? line "-")
|
(str/starts-with? line "-")
|
||||||
(let [code (subs line 1)
|
(let [code (subs line 1)
|
||||||
highlighted (str "-" (syntax/highlight-line code diff-lang "\033[38;5;210m") rst)]
|
highlighted (str "-" (syntax/highlight-line code diff-lang "\033[38;5;210m") rst)]
|
||||||
[(conj acc [:text (str " " grey (format "%4d " old-n) rst
|
[(conj acc [:text (str " " grey (format "%4d " old-n) rst
|
||||||
"\033[48;5;52m" highlighted)])
|
"\033[48;5;52m" highlighted)])
|
||||||
{:old-n (inc old-n) :new-n new-n}])
|
{:old-n (inc old-n) :new-n new-n}])
|
||||||
|
|
||||||
(str/starts-with? line "+")
|
(str/starts-with? line "+")
|
||||||
(let [code (subs line 1)
|
(let [code (subs line 1)
|
||||||
highlighted (str "+" (syntax/highlight-line code diff-lang "\033[38;5;114m") rst)]
|
highlighted (str "+" (syntax/highlight-line code diff-lang "\033[38;5;114m") rst)]
|
||||||
[(conj acc [:text (str " " grey (format "%4d " new-n) rst
|
[(conj acc [:text (str " " grey (format "%4d " new-n) rst
|
||||||
"\033[48;5;22m" highlighted)])
|
"\033[48;5;22m" highlighted)])
|
||||||
{:old-n old-n :new-n (inc new-n)}])
|
{:old-n old-n :new-n (inc new-n)}])
|
||||||
|
|
||||||
(str/starts-with? line "...")
|
(str/starts-with? line "...")
|
||||||
[(conj acc [:text {:dim true} (str " " line)])
|
[(conj acc [:text {:dim true} (str " " line)])
|
||||||
{:old-n old-n :new-n new-n}]
|
{:old-n old-n :new-n new-n}]
|
||||||
|
|
||||||
:else
|
:else
|
||||||
(let [code (if (str/starts-with? line " ") (subs line 1) line)
|
(let [code (if (str/starts-with? line " ") (subs line 1) line)
|
||||||
highlighted (str " " (syntax/highlight-line code diff-lang "") rst)]
|
highlighted (str " " (syntax/highlight-line code diff-lang "") rst)]
|
||||||
[(conj acc [:text {:dim true} (str " " grey (format "%4d " old-n) rst highlighted)])
|
[(conj acc [:text {:dim true} (str " " grey (format "%4d " old-n) rst highlighted)])
|
||||||
{:old-n (inc old-n) :new-n (inc new-n)}])))
|
{:old-n (inc old-n) :new-n (inc new-n)}])))
|
||||||
[[] {:old-n first-lineno :new-n first-lineno}]
|
[[] {:old-n first-lineno :new-n first-lineno}]
|
||||||
body-lines)]
|
body-lines)]
|
||||||
(into (if has-header?
|
(into (if has-header?
|
||||||
[[:text (str " \033[36;2m" header "\033[0m")]]
|
(let [link (when diff-path
|
||||||
|
(let [abs (if (str/starts-with? diff-path "/")
|
||||||
|
diff-path
|
||||||
|
(str (System/getProperty "user.dir") "/" diff-path))]
|
||||||
|
(str abs ":" change-start)))]
|
||||||
|
(cond-> []
|
||||||
|
link (conj [:text {:dim true} (str " " link)])
|
||||||
|
true (conj [:text (str " \033[36;2m" header "\033[0m")])))
|
||||||
[])
|
[])
|
||||||
body-els))
|
body-els))
|
||||||
|
|
||||||
@@ -385,59 +393,73 @@
|
|||||||
opts {}]
|
opts {}]
|
||||||
(cond
|
(cond
|
||||||
(nil? arg) opts
|
(nil? arg) opts
|
||||||
|
(contains? #{"--help" "-h"} arg) (assoc opts :help? true)
|
||||||
(= arg "--continue") (recur rest (assoc opts :continue? true))
|
(= arg "--continue") (recur rest (assoc opts :continue? true))
|
||||||
(= arg "--session") (let [[id & rest'] rest]
|
(= arg "--session") (let [[id & rest'] rest]
|
||||||
(recur rest' (assoc opts :session-id id)))
|
(recur rest' (assoc opts :session-id id)))
|
||||||
:else (assoc opts :prompt (str/join " " (cons arg rest))))))
|
:else (assoc opts :prompt (str/join " " (cons arg rest))))))
|
||||||
|
|
||||||
(defn -main [& args]
|
(defn -main [& args]
|
||||||
(let [{:keys [continue? session-id prompt]} (parse-args args)
|
(let [{:keys [help? continue? session-id prompt]} (parse-args args)]
|
||||||
;; Load project context and skills
|
(when help?
|
||||||
project-context (context/load-project-context)
|
(println "Usage: agent [options] [prompt]")
|
||||||
skills (context/load-skills)
|
(println)
|
||||||
_ (reset! core/skills-atom skills)
|
(println "Options:")
|
||||||
system-prompt (core/build-system-prompt project-context skills)
|
(println " --help, -h Show this help message")
|
||||||
;; Resolve session to resume
|
(println " --continue Resume the most recent session")
|
||||||
resume-id (cond
|
(println " --session <id> Resume a specific session by ID")
|
||||||
session-id session-id
|
(println)
|
||||||
continue? (core/latest-session-id))
|
(println "Examples:")
|
||||||
resumed (when resume-id (core/load-session resume-id))
|
(println " agent Start a new interactive session")
|
||||||
;; Session ID: reuse existing or generate new
|
(println " agent \"fix the bug\" Start with an initial prompt")
|
||||||
sid (or resume-id (core/new-session-id))
|
(println " agent --continue Resume the last session")
|
||||||
created (or (:created resumed) (str (java.time.Instant/now)))
|
(System/exit 0))
|
||||||
;; Build initial state from resumed session or fresh
|
(let [;; Load project context and skills
|
||||||
base-conversation (or (:conversation resumed) [])
|
project-context (context/load-project-context)
|
||||||
base-messages (or (:messages resumed) [])
|
skills (context/load-skills)
|
||||||
eq (make-event-queue)
|
_ (reset! core/skills-atom skills)
|
||||||
;; If there's a prompt, append it and start agent loop
|
system-prompt (core/build-system-prompt project-context skills)
|
||||||
[conversation messages start?]
|
;; Resolve session to resume
|
||||||
(if prompt
|
resume-id (cond
|
||||||
[(conj base-conversation {:role "user" :content prompt})
|
session-id session-id
|
||||||
(conj base-messages {:role :user :content prompt})
|
continue? (core/latest-session-id))
|
||||||
true]
|
resumed (when resume-id (core/load-session resume-id))
|
||||||
[base-conversation base-messages false])
|
;; Session ID: reuse existing or generate new
|
||||||
agent-handle (when start? (core/run-agent-loop! system-prompt conversation eq))
|
sid (or resume-id (core/new-session-id))
|
||||||
initial-model {:messages messages
|
created (or (:created resumed) (str (java.time.Instant/now)))
|
||||||
:input ""
|
;; Build initial state from resumed session or fresh
|
||||||
:conversation conversation
|
base-conversation (or (:conversation resumed) [])
|
||||||
:event-queue eq
|
base-messages (or (:messages resumed) [])
|
||||||
:session-id sid
|
eq (make-event-queue)
|
||||||
:created created
|
;; If there's a prompt, append it and start agent loop
|
||||||
:system-prompt system-prompt
|
[conversation messages start?]
|
||||||
:skills skills
|
(if prompt
|
||||||
:agent-running? start?
|
[(conj base-conversation {:role "user" :content prompt})
|
||||||
:agent-handle agent-handle
|
(conj base-messages {:role :user :content prompt})
|
||||||
:spinner-frame 0
|
true]
|
||||||
:scroll-offset 0}
|
[base-conversation base-messages false])
|
||||||
initial-events (when start?
|
agent-handle (when start? (core/run-agent-loop! system-prompt conversation eq))
|
||||||
[(ev/delayed-event 100 {:type :poll})
|
initial-model {:messages messages
|
||||||
(ev/delayed-event 80 {:type :spinner})])]
|
:input ""
|
||||||
(tui/run
|
:conversation conversation
|
||||||
{:init initial-model
|
:event-queue eq
|
||||||
:update update-fn
|
:session-id sid
|
||||||
:view view
|
:created created
|
||||||
:fps 30
|
:system-prompt system-prompt
|
||||||
:init-events initial-events})
|
:skills skills
|
||||||
;; Post-exit: print session info
|
:agent-running? start?
|
||||||
(println (str "\nSession: " sid))
|
:agent-handle agent-handle
|
||||||
(println (str "To continue: agent --session " sid))))
|
: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)))))
|
||||||
|
|||||||
+189
-86
@@ -150,8 +150,8 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
|||||||
selected (cond->> (drop start lines)
|
selected (cond->> (drop start lines)
|
||||||
limit (take limit))
|
limit (take limit))
|
||||||
numbered (map-indexed
|
numbered (map-indexed
|
||||||
(fn [i line] (str (+ start i 1) "\t" line))
|
(fn [i line] (str (+ start i 1) "\t" line))
|
||||||
selected)]
|
selected)]
|
||||||
(str (str/join "\n" numbered)
|
(str (str/join "\n" numbered)
|
||||||
"\n[" total " lines total]")))))
|
"\n[" total " lines total]")))))
|
||||||
|
|
||||||
@@ -190,11 +190,11 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
|||||||
before-ctx (subvec all-lines ctx-start start-line)
|
before-ctx (subvec all-lines ctx-start start-line)
|
||||||
after-ctx (subvec all-lines end-line ctx-end)
|
after-ctx (subvec all-lines end-line ctx-end)
|
||||||
diff-lines (concat
|
diff-lines (concat
|
||||||
[(str "@@ " path ":" (inc start-line) " @@")]
|
[(str "@@ " path ":" (inc start-line) " @@")]
|
||||||
(map #(str " " %) before-ctx)
|
(map #(str " " %) before-ctx)
|
||||||
(map #(str "-" %) old-lines)
|
(map #(str "-" %) old-lines)
|
||||||
(map #(str "+" %) new-lines)
|
(map #(str "+" %) new-lines)
|
||||||
(map #(str " " %) after-ctx))
|
(map #(str " " %) after-ctx))
|
||||||
max-lines 30]
|
max-lines 30]
|
||||||
(str/join "\n" (if (> (count diff-lines) max-lines)
|
(str/join "\n" (if (> (count diff-lines) max-lines)
|
||||||
(concat (take max-lines diff-lines) ["..."])
|
(concat (take max-lines diff-lines) ["..."])
|
||||||
@@ -228,11 +228,11 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
|||||||
truncated? (> (count lines) max-lines)
|
truncated? (> (count lines) max-lines)
|
||||||
shown (if truncated? (take max-lines lines) lines)
|
shown (if truncated? (take max-lines lines) lines)
|
||||||
diff (str/join "\n"
|
diff (str/join "\n"
|
||||||
(concat
|
(concat
|
||||||
[(str "@@ new: " path " @@")]
|
[(str "@@ new: " path " @@")]
|
||||||
(map #(str "+" %) shown)
|
(map #(str "+" %) shown)
|
||||||
(when truncated?
|
(when truncated?
|
||||||
[(str "... +" (- (count lines) max-lines) " more lines")])))]
|
[(str "... +" (- (count lines) max-lines) " more lines")])))]
|
||||||
(io/make-parents file)
|
(io/make-parents file)
|
||||||
(spit file content)
|
(spit file content)
|
||||||
{:message (str "Successfully created " path)
|
{:message (str "Successfully created " path)
|
||||||
@@ -275,24 +275,24 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
|||||||
(not (ignored-path? base-dir %))
|
(not (ignored-path? base-dir %))
|
||||||
(<= (fs/size %) (* 1024 1024)))))
|
(<= (fs/size %) (* 1024 1024)))))
|
||||||
results (reduce
|
results (reduce
|
||||||
(fn [acc file]
|
(fn [acc file]
|
||||||
(if (>= (count acc) max-matches)
|
(if (>= (count acc) max-matches)
|
||||||
(reduced acc)
|
(reduced acc)
|
||||||
(try
|
(try
|
||||||
(let [relative (str (fs/relativize base-dir file))
|
(let [relative (str (fs/relativize base-dir file))
|
||||||
lines (str/split-lines (slurp (fs/file file)))]
|
lines (str/split-lines (slurp (fs/file file)))]
|
||||||
(reduce
|
(reduce
|
||||||
(fn [acc2 [idx line]]
|
(fn [acc2 [idx line]]
|
||||||
(if (>= (count acc2) max-matches)
|
(if (>= (count acc2) max-matches)
|
||||||
(reduced acc2)
|
(reduced acc2)
|
||||||
(if (.find (.matcher compiled line))
|
(if (.find (.matcher compiled line))
|
||||||
(conj acc2 (str relative ":" (inc idx) ":" line))
|
(conj acc2 (str relative ":" (inc idx) ":" line))
|
||||||
acc2)))
|
acc2)))
|
||||||
acc
|
acc
|
||||||
(map-indexed vector lines)))
|
(map-indexed vector lines)))
|
||||||
(catch Exception _ acc))))
|
(catch Exception _ acc))))
|
||||||
[]
|
[]
|
||||||
files)
|
files)
|
||||||
truncated? (>= (count results) max-matches)]
|
truncated? (>= (count results) max-matches)]
|
||||||
(if (seq results)
|
(if (seq results)
|
||||||
(cond-> (str/join "\n" results)
|
(cond-> (str/join "\n" results)
|
||||||
@@ -515,7 +515,9 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
|||||||
{:choices [{:message (:message result)
|
{:choices [{:message (:message result)
|
||||||
:finish_reason (if (seq (get-in result [:message :tool_calls]))
|
:finish_reason (if (seq (get-in result [:message :tool_calls]))
|
||||||
"tool_calls"
|
"tool_calls"
|
||||||
"stop")}]}))
|
"stop")}]
|
||||||
|
:usage {:prompt_tokens (:prompt_eval_count result)
|
||||||
|
:completion_tokens (:eval_count result)}}))
|
||||||
|
|
||||||
;; ============================================================
|
;; ============================================================
|
||||||
;; Tool Execution
|
;; Tool Execution
|
||||||
@@ -578,10 +580,28 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
|||||||
(defn- tool-call-names [tool-calls]
|
(defn- tool-call-names [tool-calls]
|
||||||
(mapv #(get-in % [:function :name]) tool-calls))
|
(mapv #(get-in % [:function :name]) tool-calls))
|
||||||
|
|
||||||
|
(defn- detect-name-cycle
|
||||||
|
"Detects repeating cycles of tool names regardless of arguments.
|
||||||
|
e.g. [rm create_file rm create_file] has a cycle of length 2 repeating 2x.
|
||||||
|
For single-tool cycles (length 1), requires 4 repeats to avoid false positives
|
||||||
|
on legitimate back-to-back calls like read_file, read_file.
|
||||||
|
Returns the cycle length if detected, else nil."
|
||||||
|
[names-history min-repeats]
|
||||||
|
(let [n (count names-history)]
|
||||||
|
(when (>= n (* 2 min-repeats))
|
||||||
|
(first
|
||||||
|
(for [cycle-len (range 2 (inc (quot n min-repeats)))
|
||||||
|
:let [recent (take-last (* cycle-len min-repeats) names-history)
|
||||||
|
chunks (partition cycle-len recent)]
|
||||||
|
:when (and (= (count chunks) min-repeats)
|
||||||
|
(apply = chunks))]
|
||||||
|
cycle-len)))))
|
||||||
|
|
||||||
(defn- detect-stuck-loop
|
(defn- detect-stuck-loop
|
||||||
"Detects two kinds of stuck loops:
|
"Detects three kinds of stuck loops:
|
||||||
1. Exact repeat: identical tool calls N times in a row (hard stop)
|
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)
|
2. Name cycle: same pattern of tool names repeating with varying args (hard stop)
|
||||||
|
3. Research loop: delegate called N+ times with varying args (nudge, then stop)
|
||||||
Returns {:signatures, :stuck? (tool name or nil), :nudge? bool}"
|
Returns {:signatures, :stuck? (tool name or nil), :nudge? bool}"
|
||||||
[tool-calls previous-signatures repeat-threshold]
|
[tool-calls previous-signatures repeat-threshold]
|
||||||
(let [current-sigs (mapv tool-call-signature tool-calls)
|
(let [current-sigs (mapv tool-call-signature tool-calls)
|
||||||
@@ -591,23 +611,64 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
|||||||
(let [recent (take-last repeat-threshold all-sigs)]
|
(let [recent (take-last repeat-threshold all-sigs)]
|
||||||
(when (apply = recent)
|
(when (apply = recent)
|
||||||
(ffirst (last recent)))))
|
(ffirst (last recent)))))
|
||||||
;; Research loop detection: same tool name(s) used N consecutive times
|
;; Name cycle detection: same tool name pattern repeating with different args
|
||||||
;; with different arguments (e.g. varied web_search queries)
|
;; Catches e.g. [rm, create_file, rm, create_file] even when file content varies
|
||||||
names-history (mapv (fn [sigs] (set (map first sigs))) all-sigs)
|
names-history (mapv (fn [sigs] (mapv first sigs)) all-sigs)
|
||||||
|
;; 2 full repetitions of a cycle is enough to detect (e.g. rm,create,rm,create)
|
||||||
|
name-cycle? (when-not exact-stuck?
|
||||||
|
(detect-name-cycle names-history 2))
|
||||||
|
;; Research loop detection: delegate called N+ times with varying args
|
||||||
|
name-sets (mapv set names-history)
|
||||||
research-tools #{"delegate"}
|
research-tools #{"delegate"}
|
||||||
consecutive-research
|
consecutive-research
|
||||||
(count (take-while #(and (seq (set/intersection % research-tools))
|
(count (take-while #(and (seq (set/intersection % research-tools))
|
||||||
(every? research-tools %))
|
(every? research-tools %))
|
||||||
(reverse names-history)))
|
(reverse name-sets)))
|
||||||
nudge-threshold 3
|
nudge-threshold 3
|
||||||
hard-research-limit 6]
|
hard-research-limit 6]
|
||||||
{:signatures all-sigs
|
{:signatures all-sigs
|
||||||
:stuck? (or exact-stuck?
|
:stuck? (or exact-stuck?
|
||||||
|
(when name-cycle?
|
||||||
|
(str "name cycle (length " name-cycle? ")"))
|
||||||
(when (>= consecutive-research hard-research-limit)
|
(when (>= consecutive-research hard-research-limit)
|
||||||
"web_search"))
|
"web_search"))
|
||||||
:nudge? (and (not exact-stuck?)
|
:nudge? (and (not exact-stuck?)
|
||||||
|
(not name-cycle?)
|
||||||
(= consecutive-research nudge-threshold))}))
|
(= consecutive-research nudge-threshold))}))
|
||||||
|
|
||||||
|
;; ============================================================
|
||||||
|
;; Context Window Management
|
||||||
|
;; ============================================================
|
||||||
|
|
||||||
|
(defn- get-model-context-length
|
||||||
|
"Query Ollama for the model's context window size. Returns nil on failure."
|
||||||
|
[]
|
||||||
|
(try
|
||||||
|
(let [response (http/post (str ollama-host "/api/show")
|
||||||
|
{:headers {"Content-Type" "application/json"}
|
||||||
|
:body (json/generate-string {:model model})
|
||||||
|
:timeout 5000})
|
||||||
|
result (json/parse-string (:body response) true)
|
||||||
|
info (:model_info result)]
|
||||||
|
;; Context length key varies by architecture (e.g. "qwen3next.context_length", "llama.context_length")
|
||||||
|
(some (fn [[k v]] (when (str/ends-with? (name k) ".context_length") v)) info))
|
||||||
|
(catch Exception _ nil)))
|
||||||
|
|
||||||
|
(defn- trim-messages
|
||||||
|
"Reduce conversation size by truncating old tool result content.
|
||||||
|
Keeps the last `keep-recent` messages intact."
|
||||||
|
[messages keep-recent]
|
||||||
|
(let [n (count messages)
|
||||||
|
cutoff (max 0 (- n keep-recent))]
|
||||||
|
(mapv (fn [i msg]
|
||||||
|
(if (and (< i cutoff)
|
||||||
|
(= (:role msg) "tool")
|
||||||
|
(string? (:content msg))
|
||||||
|
(> (count (:content msg)) 100))
|
||||||
|
(assoc msg :content "[previous tool result truncated]")
|
||||||
|
msg))
|
||||||
|
(range) messages)))
|
||||||
|
|
||||||
;; ============================================================
|
;; ============================================================
|
||||||
;; Async Agent Loop
|
;; Async Agent Loop
|
||||||
;; ============================================================
|
;; ============================================================
|
||||||
@@ -622,15 +683,18 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
|||||||
Returns {:future f :cancel! cancel-atom}."
|
Returns {:future f :cancel! cancel-atom}."
|
||||||
[system-prompt conversation event-queue]
|
[system-prompt conversation event-queue]
|
||||||
(let [log-file (init-log)
|
(let [log-file (init-log)
|
||||||
cancelled? (atom false)]
|
cancelled? (atom false)
|
||||||
(log log-file "Agent loop started | model:" model "| messages:" (count conversation))
|
context-length (get-model-context-length)]
|
||||||
|
(log log-file "Agent loop started | model:" model "| messages:" (count conversation)
|
||||||
|
(if context-length (str "| context_length: " context-length) ""))
|
||||||
{:cancel! cancelled?
|
{:cancel! cancelled?
|
||||||
:future
|
:future
|
||||||
(future
|
(future
|
||||||
(try
|
(try
|
||||||
(loop [messages conversation
|
(loop [messages conversation
|
||||||
iteration 0
|
iteration 0
|
||||||
tool-sigs []]
|
tool-sigs []
|
||||||
|
retries 0]
|
||||||
(cond
|
(cond
|
||||||
@cancelled?
|
@cancelled?
|
||||||
(do
|
(do
|
||||||
@@ -647,55 +711,94 @@ Always explain what you're doing before using tools. Use the tools when needed t
|
|||||||
:else
|
:else
|
||||||
(do
|
(do
|
||||||
(log log-file "Iteration" iteration "| messages:" (count messages))
|
(log log-file "Iteration" iteration "| messages:" (count messages))
|
||||||
(let [response (call-llm system-prompt messages)
|
(let [[response error] (try [(call-llm system-prompt messages) nil]
|
||||||
choice (first (:choices response))
|
(catch Exception e [nil e]))]
|
||||||
message (:message choice)
|
(if error
|
||||||
finish-reason (:finish_reason choice)
|
;; LLM call failed — attempt recovery
|
||||||
content (:content message)
|
(if @cancelled?
|
||||||
tool-calls (:tool_calls message)]
|
(do
|
||||||
(log log-file "finish_reason:" finish-reason "| tool_calls:" (count (or tool-calls [])))
|
(log log-file "Agent interrupted by user during LLM call")
|
||||||
;; Push assistant text
|
(swap! event-queue conj {:type :error :message "Interrupted."})
|
||||||
(when (and content (seq (str/trim content)))
|
(swap! event-queue conj {:type :done :conversation messages}))
|
||||||
(swap! event-queue conj {:type :text :content content}))
|
(if (< retries 2)
|
||||||
;; Handle tool calls or finish
|
(let [keep-n (if (zero? retries) 10 4)
|
||||||
(if (and (= finish-reason "tool_calls") (seq tool-calls))
|
trimmed (trim-messages messages keep-n)]
|
||||||
(let [{:keys [signatures stuck? nudge?]} (detect-stuck-loop tool-calls tool-sigs 3)]
|
(log log-file "LLM call failed:" (str error) "— retry" (inc retries) "with trimmed context (keeping last" keep-n ")")
|
||||||
(cond
|
(swap! event-queue conj {:type :error :message (str "LLM error, trimming context and retrying... (" (str error) ")")})
|
||||||
stuck?
|
(recur trimmed iteration tool-sigs (inc retries)))
|
||||||
(do
|
(do
|
||||||
(log log-file "Stuck loop detected:" stuck?)
|
(log log-file "LLM call failed after retries:" (str error))
|
||||||
(swap! event-queue conj {:type :error :message "Agent is repeating the same action."})
|
(swap! event-queue conj {:type :error :message (str error)})
|
||||||
(swap! event-queue conj {:type :done :conversation messages}))
|
(swap! event-queue conj {:type :done :conversation messages}))))
|
||||||
|
|
||||||
:else
|
;; LLM call succeeded
|
||||||
|
(let [choice (first (:choices response))
|
||||||
|
message (:message choice)
|
||||||
|
finish-reason (:finish_reason choice)
|
||||||
|
content (:content message)
|
||||||
|
tool-calls (:tool_calls message)
|
||||||
|
;; Context window tracking
|
||||||
|
prompt-tokens (get-in response [:usage :prompt_tokens])
|
||||||
|
completion-tokens (get-in response [:usage :completion_tokens])]
|
||||||
|
(log log-file "finish_reason:" finish-reason
|
||||||
|
"| tool_calls:" (count (or tool-calls []))
|
||||||
|
(when prompt-tokens (str "| tokens: " prompt-tokens "/" (or context-length "?")
|
||||||
|
" (" (when context-length (str (int (* 100 (/ prompt-tokens context-length))) "%")) ")")))
|
||||||
|
;; Warn user when approaching context limit
|
||||||
|
(when (and context-length prompt-tokens
|
||||||
|
(> (/ prompt-tokens context-length) 0.80))
|
||||||
|
(let [pct (int (* 100 (/ prompt-tokens context-length)))]
|
||||||
|
(log log-file "Context window warning:" pct "% used")
|
||||||
|
(swap! event-queue conj
|
||||||
|
{:type :error
|
||||||
|
:message (str "Context " pct "% full (" prompt-tokens "/" context-length " tokens). Responses may degrade.")})))
|
||||||
|
;; 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)]
|
||||||
|
;; Push diffs to UI
|
||||||
|
(doseq [tr tool-results]
|
||||||
|
(when-let [diff (:diff tr)]
|
||||||
|
(swap! event-queue conj {:type :diff :content diff})))
|
||||||
|
(let [clean-results (mapv #(dissoc % :diff) tool-results)
|
||||||
|
assistant-msg (select-keys message [:role :content :tool_calls])
|
||||||
|
new-messages (into (conj messages assistant-msg) clean-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 0))))))
|
||||||
|
;; Done - no more tool calls
|
||||||
(do
|
(do
|
||||||
;; Show tool activities
|
(log log-file "Agent finished after" iteration "iterations")
|
||||||
(doseq [tc tool-calls]
|
(swap! event-queue conj
|
||||||
(swap! event-queue conj {:type :tool :label (tool-call-label tc)}))
|
{:type :done
|
||||||
;; Execute tools
|
:conversation (conj messages
|
||||||
(let [tool-results (mapv #(execute-tool log-file %) tool-calls)]
|
(select-keys message [:role :content]))})))))))))
|
||||||
;; Push diffs to UI
|
(catch java.net.ConnectException _
|
||||||
(doseq [tr tool-results]
|
(let [msg (str "Could not reach model server at " ollama-host " — is Ollama running?")]
|
||||||
(when-let [diff (:diff tr)]
|
(log log-file "Agent error:" msg)
|
||||||
(swap! event-queue conj {:type :diff :content diff})))
|
(swap! event-queue conj {:type :error :message msg})
|
||||||
(let [clean-results (mapv #(dissoc % :diff) tool-results)
|
(swap! event-queue conj {:type :done :conversation conversation})))
|
||||||
assistant-msg (select-keys message [:role :content :tool_calls])
|
|
||||||
new-messages (into (conj messages assistant-msg) clean-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
|
(catch Exception e
|
||||||
(log log-file "Agent error:" (str e))
|
(log log-file "Agent error:" (str e))
|
||||||
(swap! event-queue conj {:type :error :message (str e)})
|
(swap! event-queue conj {:type :error :message (str e)})
|
||||||
|
|||||||
Reference in New Issue
Block a user