Skip to content

Commit 2259f77

Browse files
leifericfclaude
andcommitted
feat(ask): return partial answers on budget exhaustion + resumable sessions
Instead of returning an error when the agent runs out of iterations, nudge it to synthesize its best answer on the last iteration and return that answer (with a session ID for continuation). Users can resume budget-exhausted sessions via continue_from without re-spending tokens. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9051ca6 commit 2259f77

6 files changed

Lines changed: 163 additions & 36 deletions

File tree

resources/prompts/agent-system.edn

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,5 @@ These are pre-built named queries that demonstrate valid Datalog patterns. Use t
4444
5. If a query returns empty results or an error, reformulate and try again.
4545
6. When you have enough information, emit {:tool :answer :args {:text \"...\"}} with your answer.
4646
7. Be precise — cite specific entities, paths, or values from query results.
47-
8. Keep queries focused. Prefer multiple simple queries over one complex query. Chain results across queries to build up a complete answer."}
47+
8. Keep queries focused. Prefer multiple simple queries over one complex query. Chain results across queries to build up a complete answer.
48+
9. You have a limited iteration budget. Do not save your answer for later — if you have enough information to answer (even partially), emit {:tool :answer} promptly. You will be warned when you are about to run out of iterations."}

src/noumenon/agent.clj

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,47 @@
184184
{:result (str "Unknown tool: " (:tool tool-call)
185185
". Available: :query, :schema, :rules, :answer")})))
186186

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+
187217
;; --- Agent loop ---
188218

189219
(def ^:private default-max-iterations 10)
190220

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+
191228
(defn- parse-error-transition
192229
[messages response-text parse-error]
193230
(let [error-msg (str "Your response could not be parsed as EDN. Error: "
@@ -203,16 +240,29 @@
203240
{:role "assistant" :content response-text}
204241
{:role "user" :content (str "Tool result:\n" result)}))
205242

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+
206250
(defn- next-state
207251
[{:keys [db invoke-fn]}
208252
{:keys [messages steps iterations total-usage max-iterations]}]
209253
(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))
216266
usage (merge-with + total-usage
217267
(select-keys (:usage response)
218268
[:input-tokens :output-tokens :cost-usd :duration-ms]))
@@ -240,16 +290,20 @@
240290

241291
(defn ask
242292
"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]
245297
: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})]
253307
(loop [state initial]
254308
(let [nxt (next-state context state)]
255309
(if-let [done (:done nxt)]

src/noumenon/cli.clj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,8 @@
217217
:desc "Max query iterations (default: 10)"
218218
:error-invalid :invalid-max-iterations
219219
:error-missing :missing-max-iterations-value}
220+
{:flag "--continue-from" :key :continue-from :parse :string
221+
:desc "Session ID from a budget-exhausted run — resumes the agent"}
220222
db-dir-flag]
221223
verbose-flags))
222224
:initial {:subcommand "ask"}

src/noumenon/main.clj

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@
237237

238238
(defn do-ask
239239
"Run the ask subcommand. Returns {:exit n :result map-or-nil}."
240-
[{:keys [question model provider max-iterations verbose] :as opts}]
240+
[{:keys [question model provider max-iterations continue-from verbose] :as opts}]
241241
(with-valid-repo
242242
opts
243243
(fn [ctx]
@@ -252,7 +252,8 @@
252252
:max-tokens 4096})
253253
result (agent/ask db question
254254
(cond-> {:invoke-fn invoke-fn :repo-name db-name}
255-
max-iterations (assoc :max-iterations max-iterations)))]
255+
max-iterations (assoc :max-iterations max-iterations)
256+
continue-from (assoc :continue-from continue-from)))]
256257
(when verbose
257258
(let [max-iters (or max-iterations 10)]
258259
(doseq [step (:steps result)]
@@ -267,15 +268,27 @@
267268
" chars)"))
268269
:else "thinking")]
269270
(log! (str " [" i "/" max-iters "] " tag))))))
270-
(let [exit-code (if (= :budget-exhausted (:status result)) 2 0)]
271-
(if (= :budget-exhausted (:status result))
272-
(log! "Budget exhausted — no answer found.")
273-
(when-let [answer (:answer result)]
274-
(log! answer)))
275-
{:exit exit-code
276-
:result {:answer (:answer result)
277-
:status (:status result)
278-
:usage (:usage result)}}))))
271+
(let [exhausted? (= :budget-exhausted (:status result))
272+
answer (:answer result)
273+
session-id (:session-id result)]
274+
(cond
275+
(and exhausted? (not answer))
276+
(do (log! "Budget exhausted — no answer found.")
277+
{:exit 2 :result {:status :budget-exhausted :usage (:usage result)}})
278+
279+
exhausted?
280+
(do (log! answer)
281+
(log! (str "\n[Session " session-id " saved — re-run with"
282+
" --continue-from " session-id " to resume]"))
283+
{:exit 2
284+
:result {:answer answer :status :budget-exhausted
285+
:session-id session-id :usage (:usage result)}})
286+
287+
:else
288+
(do (when answer (log! answer))
289+
{:exit 0
290+
:result {:answer answer :status (:status result)
291+
:usage (:usage result)}}))))))
279292
(catch clojure.lang.ExceptionInfo e
280293
(print-error! (.getMessage e))
281294
{:exit 1})))))

src/noumenon/mcp.clj

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,8 @@
144144
{"question" {:type "string" :description "Question to ask about the repository"}
145145
"provider" {:type "string" :description "LLM provider: glm, claude-api, or claude-cli (aliases: claude = claude-cli)"}
146146
"model" {:type "string" :description "Model alias (e.g. sonnet, haiku, opus)"}
147-
"max_iterations" {:type "integer" :description "Max query iterations (default: 10, max: 50)"}})
147+
"max_iterations" {:type "integer" :description "Max query iterations (default: 10, max: 50)"}
148+
"continue_from" {:type "string" :description "Session ID from a budget-exhausted run — resumes the agent from where it left off"}})
148149
:required ["question" "repo_path"]}}
149150
{:name "noumenon_analyze"
150151
:description "Run LLM analysis on repository files to enrich the knowledge graph with semantic metadata. Only analyzes files not yet analyzed. Requires a prior import."
@@ -336,18 +337,25 @@
336337
result (agent/ask db (args "question")
337338
{:invoke-fn invoke-fn
338339
:repo-name db-name
339-
:max-iterations max-iter})
340-
usage (:usage result)]
340+
:max-iterations max-iter
341+
:continue-from (args "continue_from")})
342+
usage (:usage result)
343+
answer (:answer result)
344+
session-id (:session-id result)]
341345
(log! "agent/done"
342346
(str "status=" (:status result)
343347
" iterations=" (:iterations usage)
344348
" tokens=" (+ (:input-tokens usage 0) (:output-tokens usage 0))))
345349
(if (= :budget-exhausted (:status result))
346-
(tool-error (str "Budget exhausted after " max-iter " iterations"
347-
" (" (:input-tokens usage 0) " in / "
348-
(:output-tokens usage 0) " out tokens). "
349-
"Try increasing max_iterations or narrowing the question."))
350-
(tool-result (or (:answer result)
350+
(if answer
351+
(tool-result (str answer
352+
"\n\n[Session " session-id " saved — to continue exploring, "
353+
"call noumenon_ask with continue_from=\"" session-id "\"]"))
354+
(tool-error (str "Budget exhausted after " max-iter " iterations"
355+
" (" (:input-tokens usage 0) " in / "
356+
(:output-tokens usage 0) " out tokens) with no answer. "
357+
"Try increasing max_iterations or narrowing the question.")))
358+
(tool-result (or answer
351359
(str "No answer found (status: " (name (:status result)) ")"))))))))
352360

353361
(defn- handle-analyze [args defaults]

test/noumenon/agent_test.clj

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,56 @@
266266
:max-iterations 3})]
267267
(is (= :budget-exhausted (:status result)))
268268
(is (<= @call-count 3))
269-
(is (= 3 (get-in result [:usage :iterations])))))
269+
(is (= 3 (get-in result [:usage :iterations])))
270+
(is (string? (:session-id result)))))
271+
272+
(deftest ask-sends-nudge-on-last-iteration
273+
(testing "agent receives budget nudge message on final iteration"
274+
(let [db (make-test-db)
275+
last-messages (atom nil)
276+
call-count (atom 0)
277+
mock-llm (fn [messages]
278+
(reset! last-messages messages)
279+
(swap! call-count inc)
280+
(if (>= @call-count 3)
281+
{:text "{:tool :answer :args {:text \"partial answer\"}}"
282+
:usage {:input-tokens 100 :output-tokens 50}
283+
:model "mock"}
284+
{:text "{:tool :query :args {:query [:find ?p :where [?e :file/path ?p]]}}"
285+
:usage {:input-tokens 100 :output-tokens 50}
286+
:model "mock"}))
287+
result (agent/ask db "test" {:invoke-fn mock-llm :repo-name "test"
288+
:max-iterations 3})]
289+
(is (= :answered (:status result)))
290+
(is (= "partial answer" (:answer result)))
291+
(is (re-find #"iteration budget" (:content (last @last-messages)))))))
292+
293+
(deftest ask-session-store-and-continue
294+
(testing "budget-exhausted session can be resumed with continue-from"
295+
(let [db (make-test-db)
296+
call-count (atom 0)
297+
mock-llm (fn [_messages]
298+
(swap! call-count inc)
299+
(if (>= @call-count 4)
300+
{:text "{:tool :answer :args {:text \"full answer\"}}"
301+
:usage {:input-tokens 100 :output-tokens 50}
302+
:model "mock"}
303+
{:text "{:tool :query :args {:query [:find ?p :where [?e :file/path ?p]]}}"
304+
:usage {:input-tokens 100 :output-tokens 50}
305+
:model "mock"}))
306+
;; First run: exhaust budget after 2 iterations
307+
result1 (agent/ask db "test" {:invoke-fn mock-llm :repo-name "test"
308+
:max-iterations 2})
309+
_ (is (= :budget-exhausted (:status result1)))
310+
session-id (:session-id result1)
311+
_ (is (some? session-id))
312+
;; Continue: resume with more budget
313+
result2 (agent/ask db "test" {:invoke-fn mock-llm
314+
:repo-name "test"
315+
:max-iterations 5
316+
:continue-from session-id})]
317+
(is (= :answered (:status result2)))
318+
(is (= "full answer" (:answer result2))))))
270319

271320
;; --- Tier 1: integration test with mock LLM ---
272321

0 commit comments

Comments
 (0)