add diff links (for quick edits)

This commit is contained in:
2026-03-09 23:20:02 -04:00
parent 813972f8da
commit e8fc61518c
4 changed files with 296 additions and 167 deletions
+1
View File
@@ -1,3 +1,4 @@
logs/
.clj-kondo/
.lsp/
.cpcache/
+3
View File
@@ -0,0 +1,3 @@
{:paths ["src"]
:deps {tui/tui {:git/url "https://git.ajet.fyi/ajet/clojure-tui.git"
:git/sha "4a051304888d5b4d937262decba919e1a79dd03d"}}}
+103 -81
View File
@@ -132,12 +132,13 @@
change-start (if has-header?
(or (some-> (re-find #":(\d+)" header) second parse-long) 1)
1)
;; Extract file extension from @@ header for syntax highlighting
diff-lang (when has-header?
(when-let [path (second (re-find #"@@\s+(?:new:\s+)?(\S+)" header))]
(let [dot-idx (str/last-index-of path ".")]
(when dot-idx
(syntax/lang-for-ext (subs path dot-idx))))))
;; Extract file path and extension from @@ header
diff-path (when has-header?
(second (re-find #"@@\s+(?:new:\s+)?(\S+)" header)))
diff-lang (when diff-path
(let [dot-idx (str/last-index-of diff-path ".")]
(when dot-idx
(syntax/lang-for-ext (subs diff-path dot-idx)))))
body-lines (if has-header? (rest raw-lines) raw-lines)
ctx-before (count (take-while #(str/starts-with? % " ") body-lines))
first-lineno (- change-start ctx-before)
@@ -145,35 +146,42 @@
rst "\033[0m"
[body-els _]
(reduce
(fn [[acc {:keys [old-n new-n]}] line]
(cond
(str/starts-with? line "-")
(let [code (subs line 1)
highlighted (str "-" (syntax/highlight-line code diff-lang "\033[38;5;210m") rst)]
[(conj acc [:text (str " " grey (format "%4d " old-n) rst
"\033[48;5;52m" highlighted)])
{:old-n (inc old-n) :new-n new-n}])
(fn [[acc {:keys [old-n new-n]}] line]
(cond
(str/starts-with? line "-")
(let [code (subs line 1)
highlighted (str "-" (syntax/highlight-line code diff-lang "\033[38;5;210m") rst)]
[(conj acc [:text (str " " grey (format "%4d " old-n) rst
"\033[48;5;52m" highlighted)])
{:old-n (inc old-n) :new-n new-n}])
(str/starts-with? line "+")
(let [code (subs line 1)
highlighted (str "+" (syntax/highlight-line code diff-lang "\033[38;5;114m") rst)]
[(conj acc [:text (str " " grey (format "%4d " new-n) rst
"\033[48;5;22m" highlighted)])
{:old-n old-n :new-n (inc new-n)}])
(str/starts-with? line "+")
(let [code (subs line 1)
highlighted (str "+" (syntax/highlight-line code diff-lang "\033[38;5;114m") rst)]
[(conj acc [:text (str " " grey (format "%4d " new-n) rst
"\033[48;5;22m" highlighted)])
{:old-n old-n :new-n (inc new-n)}])
(str/starts-with? line "...")
[(conj acc [:text {:dim true} (str " " line)])
{:old-n old-n :new-n new-n}]
(str/starts-with? line "...")
[(conj acc [:text {:dim true} (str " " line)])
{:old-n old-n :new-n new-n}]
:else
(let [code (if (str/starts-with? line " ") (subs line 1) line)
highlighted (str " " (syntax/highlight-line code diff-lang "") rst)]
[(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 first-lineno :new-n first-lineno}]
body-lines)]
:else
(let [code (if (str/starts-with? line " ") (subs line 1) line)
highlighted (str " " (syntax/highlight-line code diff-lang "") rst)]
[(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 first-lineno :new-n first-lineno}]
body-lines)]
(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))
@@ -385,59 +393,73 @@
opts {}]
(cond
(nil? arg) opts
(contains? #{"--help" "-h"} arg) (assoc opts :help? true)
(= 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)
;; Load project context and skills
project-context (context/load-project-context)
skills (context/load-skills)
_ (reset! core/skills-atom skills)
system-prompt (core/build-system-prompt project-context skills)
;; 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! system-prompt conversation eq))
initial-model {:messages messages
:input ""
:conversation conversation
:event-queue eq
:session-id sid
:created created
:system-prompt system-prompt
:skills skills
: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))))
(let [{:keys [help? continue? session-id prompt]} (parse-args args)]
(when help?
(println "Usage: agent [options] [prompt]")
(println)
(println "Options:")
(println " --help, -h Show this help message")
(println " --continue Resume the most recent session")
(println " --session <id> Resume a specific session by ID")
(println)
(println "Examples:")
(println " agent Start a new interactive session")
(println " agent \"fix the bug\" Start with an initial prompt")
(println " agent --continue Resume the last session")
(System/exit 0))
(let [;; Load project context and skills
project-context (context/load-project-context)
skills (context/load-skills)
_ (reset! core/skills-atom skills)
system-prompt (core/build-system-prompt project-context skills)
;; 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! system-prompt conversation eq))
initial-model {:messages messages
:input ""
:conversation conversation
:event-queue eq
:session-id sid
:created created
:system-prompt system-prompt
:skills skills
: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)))))
+189 -86
View File
@@ -150,8 +150,8 @@ Always explain what you're doing before using tools. Use the tools when needed t
selected (cond->> (drop start lines)
limit (take limit))
numbered (map-indexed
(fn [i line] (str (+ start i 1) "\t" line))
selected)]
(fn [i line] (str (+ start i 1) "\t" line))
selected)]
(str (str/join "\n" numbered)
"\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)
after-ctx (subvec all-lines end-line ctx-end)
diff-lines (concat
[(str "@@ " path ":" (inc start-line) " @@")]
(map #(str " " %) before-ctx)
(map #(str "-" %) old-lines)
(map #(str "+" %) new-lines)
(map #(str " " %) after-ctx))
[(str "@@ " path ":" (inc start-line) " @@")]
(map #(str " " %) before-ctx)
(map #(str "-" %) old-lines)
(map #(str "+" %) new-lines)
(map #(str " " %) after-ctx))
max-lines 30]
(str/join "\n" (if (> (count diff-lines) max-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)
shown (if truncated? (take max-lines lines) lines)
diff (str/join "\n"
(concat
[(str "@@ new: " path " @@")]
(map #(str "+" %) shown)
(when truncated?
[(str "... +" (- (count lines) max-lines) " more lines")])))]
(concat
[(str "@@ new: " path " @@")]
(map #(str "+" %) shown)
(when truncated?
[(str "... +" (- (count lines) max-lines) " more lines")])))]
(io/make-parents file)
(spit file content)
{: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 %))
(<= (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)
(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)
@@ -515,7 +515,9 @@ Always explain what you're doing before using tools. Use the tools when needed t
{:choices [{:message (:message result)
:finish_reason (if (seq (get-in result [:message :tool_calls]))
"tool_calls"
"stop")}]}))
"stop")}]
:usage {:prompt_tokens (:prompt_eval_count result)
:completion_tokens (:eval_count result)}}))
;; ============================================================
;; 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]
(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
"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)
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}"
[tool-calls previous-signatures repeat-threshold]
(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)]
(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)
;; Name cycle detection: same tool name pattern repeating with different args
;; Catches e.g. [rm, create_file, rm, create_file] even when file content varies
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"}
consecutive-research
(count (take-while #(and (seq (set/intersection % research-tools))
(every? research-tools %))
(reverse names-history)))
(reverse name-sets)))
nudge-threshold 3
hard-research-limit 6]
{:signatures all-sigs
:stuck? (or exact-stuck?
(when name-cycle?
(str "name cycle (length " name-cycle? ")"))
(when (>= consecutive-research hard-research-limit)
"web_search"))
:nudge? (and (not exact-stuck?)
(not name-cycle?)
(= 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
;; ============================================================
@@ -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}."
[system-prompt conversation event-queue]
(let [log-file (init-log)
cancelled? (atom false)]
(log log-file "Agent loop started | model:" model "| messages:" (count conversation))
cancelled? (atom false)
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?
:future
(future
(try
(loop [messages conversation
iteration 0
tool-sigs []]
tool-sigs []
retries 0]
(cond
@cancelled?
(do
@@ -647,55 +711,94 @@ Always explain what you're doing before using tools. Use the tools when needed t
:else
(do
(log log-file "Iteration" iteration "| messages:" (count messages))
(let [response (call-llm system-prompt 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?
(let [[response error] (try [(call-llm system-prompt messages) nil]
(catch Exception e [nil e]))]
(if error
;; LLM call failed — attempt recovery
(if @cancelled?
(do
(log log-file "Agent interrupted by user during LLM call")
(swap! event-queue conj {:type :error :message "Interrupted."})
(swap! event-queue conj {:type :done :conversation messages}))
(if (< retries 2)
(let [keep-n (if (zero? retries) 10 4)
trimmed (trim-messages messages keep-n)]
(log log-file "LLM call failed:" (str error) "— retry" (inc retries) "with trimmed context (keeping last" keep-n ")")
(swap! event-queue conj {:type :error :message (str "LLM error, trimming context and retrying... (" (str error) ")")})
(recur trimmed iteration tool-sigs (inc retries)))
(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}))
(log log-file "LLM call failed after retries:" (str error))
(swap! event-queue conj {:type :error :message (str error)})
(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
;; 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))))))
;; 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]))})))))))
(log log-file "Agent finished after" iteration "iterations")
(swap! event-queue conj
{:type :done
:conversation (conj messages
(select-keys message [:role :content]))})))))))))
(catch java.net.ConnectException _
(let [msg (str "Could not reach model server at " ollama-host " — is Ollama running?")]
(log log-file "Agent error:" msg)
(swap! event-queue conj {:type :error :message msg})
(swap! event-queue conj {:type :done :conversation conversation})))
(catch Exception e
(log log-file "Agent error:" (str e))
(swap! event-queue conj {:type :error :message (str e)})