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"}}}
|
||||||
+31
-9
@@ -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
@@ -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)})
|
||||||
|
|||||||
Reference in New Issue
Block a user