diff --git a/.gitignore b/.gitignore index 0bad600..f8a2eab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ logs/ .clj-kondo/ .lsp/ +.cpcache/ diff --git a/deps.edn b/deps.edn new file mode 100644 index 0000000..e788f91 --- /dev/null +++ b/deps.edn @@ -0,0 +1,3 @@ +{:paths ["src"] + :deps {tui/tui {:git/url "https://git.ajet.fyi/ajet/clojure-tui.git" + :git/sha "4a051304888d5b4d937262decba919e1a79dd03d"}}} diff --git a/src/agent/app.clj b/src/agent/app.clj index e45440f..a5672c3 100644 --- a/src/agent/app.clj +++ b/src/agent/app.clj @@ -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 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))))) diff --git a/src/agent/core.clj b/src/agent/core.clj index f4bce63..318cc50 100644 --- a/src/agent/core.clj +++ b/src/agent/core.clj @@ -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)})