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/ logs/
.clj-kondo/ .clj-kondo/
.lsp/ .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"}}}
+31 -9
View File
@@ -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
(let [dot-idx (str/last-index-of diff-path ".")]
(when dot-idx (when dot-idx
(syntax/lang-for-ext (subs path 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)
@@ -173,7 +174,14 @@
[[] {: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,14 +393,28 @@
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?
(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) project-context (context/load-project-context)
skills (context/load-skills) skills (context/load-skills)
_ (reset! core/skills-atom skills) _ (reset! core/skills-atom skills)
@@ -440,4 +462,4 @@
:init-events initial-events}) :init-events initial-events})
;; Post-exit: print session info ;; Post-exit: print session info
(println (str "\nSession: " sid)) (println (str "\nSession: " sid))
(println (str "To continue: agent --session " sid)))) (println (str "To continue: agent --session " sid)))))
+119 -16
View File
@@ -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,13 +711,47 @@ 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]))]
(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 "LLM call failed after retries:" (str error))
(swap! event-queue conj {:type :error :message (str error)})
(swap! event-queue conj {:type :done :conversation messages}))))
;; LLM call succeeded
(let [choice (first (:choices response))
message (:message choice) message (:message choice)
finish-reason (:finish_reason choice) finish-reason (:finish_reason choice)
content (:content message) content (:content message)
tool-calls (:tool_calls message)] tool-calls (:tool_calls message)
(log log-file "finish_reason:" finish-reason "| tool_calls:" (count (or tool-calls []))) ;; 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 ;; Push assistant text
(when (and content (seq (str/trim content))) (when (and content (seq (str/trim content)))
(swap! event-queue conj {:type :text :content content})) (swap! event-queue conj {:type :text :content content}))
@@ -688,14 +786,19 @@ Always explain what you're doing before using tools. Use the tools when needed t
{:role "system" {: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."})) :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)] new-messages)]
(recur new-messages (inc iteration) signatures)))))) (recur new-messages (inc iteration) signatures 0))))))
;; Done - no more tool calls ;; Done - no more tool calls
(do (do
(log log-file "Agent finished after" iteration "iterations") (log log-file "Agent finished after" iteration "iterations")
(swap! event-queue conj (swap! event-queue conj
{:type :done {:type :done
:conversation (conj messages :conversation (conj messages
(select-keys message [:role :content]))}))))))) (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 (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)})