|
184 | 184 | {:result (str "Unknown tool: " (:tool tool-call) |
185 | 185 | ". Available: :query, :schema, :rules, :answer")}))) |
186 | 186 |
|
| 187 | +;; --- Session storage (for resumable budget-exhausted runs) --- |
| 188 | + |
| 189 | +(def ^:private max-sessions 20) |
| 190 | +(def ^:private session-ttl-ms (* 30 60 1000)) ;; 30 minutes |
| 191 | + |
| 192 | +(def ^:private sessions (atom {})) |
| 193 | + |
| 194 | +(defn- evict-expired-sessions! [] |
| 195 | + (let [cutoff (- (System/currentTimeMillis) session-ttl-ms)] |
| 196 | + (swap! sessions (fn [m] |
| 197 | + (let [live (into {} (remove #(< (:created-at (val %)) cutoff)) m)] |
| 198 | + (if (<= (count live) max-sessions) |
| 199 | + live |
| 200 | + (->> live |
| 201 | + (sort-by (comp :created-at val)) |
| 202 | + (drop (- (count live) max-sessions)) |
| 203 | + (into {})))))))) |
| 204 | + |
| 205 | +(defn- store-session! [state] |
| 206 | + (evict-expired-sessions!) |
| 207 | + (let [id (str (java.util.UUID/randomUUID))] |
| 208 | + (swap! sessions assoc id (assoc state :created-at (System/currentTimeMillis))) |
| 209 | + id)) |
| 210 | + |
| 211 | +(defn- load-session! [id] |
| 212 | + (let [s (get @sessions id)] |
| 213 | + (when s |
| 214 | + (swap! sessions dissoc id) |
| 215 | + (dissoc s :created-at)))) |
| 216 | + |
187 | 217 | ;; --- Agent loop --- |
188 | 218 |
|
189 | 219 | (def ^:private default-max-iterations 10) |
190 | 220 |
|
| 221 | +(def ^:private budget-nudge |
| 222 | + (str "You have reached your iteration budget. " |
| 223 | + "You MUST respond with {:tool :answer :args {:text \"...\"}} NOW. " |
| 224 | + "Synthesize the best answer you can from the information gathered so far. " |
| 225 | + "If your answer is incomplete, end with a note about what additional queries " |
| 226 | + "you would have run given more budget.")) |
| 227 | + |
191 | 228 | (defn- parse-error-transition |
192 | 229 | [messages response-text parse-error] |
193 | 230 | (let [error-msg (str "Your response could not be parsed as EDN. Error: " |
|
203 | 240 | {:role "assistant" :content response-text} |
204 | 241 | {:role "user" :content (str "Tool result:\n" result)})) |
205 | 242 |
|
| 243 | +(defn- maybe-append-nudge |
| 244 | + "Append the budget nudge if this is the last allowed iteration." |
| 245 | + [messages iterations max-iterations] |
| 246 | + (if (>= iterations (dec max-iterations)) |
| 247 | + (conj messages {:role "user" :content budget-nudge}) |
| 248 | + messages)) |
| 249 | + |
206 | 250 | (defn- next-state |
207 | 251 | [{:keys [db invoke-fn]} |
208 | 252 | {:keys [messages steps iterations total-usage max-iterations]}] |
209 | 253 | (if (>= iterations max-iterations) |
210 | | - {:done {:answer (or (some :answer steps) |
211 | | - "Budget exhausted: reached maximum iterations without a final answer.") |
212 | | - :steps steps |
213 | | - :usage (assoc total-usage :iterations iterations) |
214 | | - :status :budget-exhausted}} |
215 | | - (let [response (invoke-fn (vec messages)) |
| 254 | + (let [state {:messages messages :steps steps |
| 255 | + :iterations iterations :total-usage total-usage |
| 256 | + :max-iterations max-iterations} |
| 257 | + session-id (store-session! state)] |
| 258 | + {:done {:answer (or (some :answer steps) |
| 259 | + "Budget exhausted: reached maximum iterations without a final answer.") |
| 260 | + :steps steps |
| 261 | + :usage (assoc total-usage :iterations iterations) |
| 262 | + :status :budget-exhausted |
| 263 | + :session-id session-id}}) |
| 264 | + (let [msgs (maybe-append-nudge messages iterations max-iterations) |
| 265 | + response (invoke-fn (vec msgs)) |
216 | 266 | usage (merge-with + total-usage |
217 | 267 | (select-keys (:usage response) |
218 | 268 | [:input-tokens :output-tokens :cost-usd :duration-ms])) |
|
240 | 290 |
|
241 | 291 | (defn ask |
242 | 292 | "Run the agent loop: prompt → parse → dispatch → repeat. |
243 | | - Returns {:answer string :steps vec :usage {:iterations n :input-tokens n :output-tokens n}}." |
244 | | - [db question {:keys [invoke-fn repo-name max-iterations] |
| 293 | + Returns {:answer string :steps vec :usage {:iterations n :input-tokens n :output-tokens n} |
| 294 | + :status :answered|:budget-exhausted :session-id string?}. |
| 295 | + Pass :continue-from session-id to resume a budget-exhausted session." |
| 296 | + [db question {:keys [invoke-fn repo-name max-iterations continue-from] |
245 | 297 | :or {max-iterations default-max-iterations}}] |
246 | | - (let [system-prompt (build-system-prompt db repo-name) |
247 | | - context {:db db :invoke-fn invoke-fn} |
248 | | - initial {:messages [{:role "user" :content (str system-prompt "\n\n" question)}] |
249 | | - :steps [] |
250 | | - :iterations 0 |
251 | | - :total-usage llm/zero-usage |
252 | | - :max-iterations max-iterations}] |
| 298 | + (let [context {:db db :invoke-fn invoke-fn} |
| 299 | + initial (if-let [prev (when continue-from (load-session! continue-from))] |
| 300 | + (assoc prev :max-iterations (+ (:iterations prev) max-iterations)) |
| 301 | + {:messages [{:role "user" :content (str (build-system-prompt db repo-name) |
| 302 | + "\n\n" question)}] |
| 303 | + :steps [] |
| 304 | + :iterations 0 |
| 305 | + :total-usage llm/zero-usage |
| 306 | + :max-iterations max-iterations})] |
253 | 307 | (loop [state initial] |
254 | 308 | (let [nxt (next-state context state)] |
255 | 309 | (if-let [done (:done nxt)] |
|
0 commit comments