feat: usage guards (max tool calls / cost budget) for the tool-calling loop
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 176
- Forks
- 28
- Avg merge
- 18h 42m
- Merged PRs (30d)
- 16
Description
Motivation
Pydantic AI ships UsageLimits(request_limit=..., tool_calls_limit=...), raising a typed UsageLimitExceeded when an agentic loop crosses a caller-set ceiling (https://ai.pydantic.dev/agents/#usage-limits); LiteLLM's proxy layer offers budget caps for the same reason. Chatlas's tool-calling loop has no equivalent. The loop lives in Chat._chat_impl() / _chat_impl_async() in chatlas/_chat.py (while user_turn_result is not None:, currently around line 2661/2748): each pass submits turns, scans the assistant turn's contents for ContentToolRequests, invokes them via self._invoke_tool()/self._invoke_tool_async(), and if any tool produced results, wraps them in a new UserTurn and loops again — with no cap on iterations, tool-call count, or spend. A model that keeps requesting tools (a bad prompt, a confused agent, a tool that always looks "incomplete" to the model) can run indefinitely and rack up real cost for a solo developer, especially in unattended scripts.
This is the same gap the ellmer community flagged in tidyverse/ellmer#958 ("Token Budget / Cap for Agentic Tool Call Loops," open), which specifically asks for (1) a hard token/call budget that stops the loop gracefully, and (2) an optional soft-threshold nudge ("you're approaching your limit, wrap up now") so the model can hand back a partial answer instead of a hard failure. A commenter there stressed that a bare cap without a structured "stop reason" (what was pending, last tool call, etc.) just pushes the debugging burden onto whoever hits it — worth designing the exception/return value around from the start.
Proposed approach
import chatlas as ctl
chat = ctl.ChatAnthropic()
chat.register_tool(my_tool)
# Hard stop: raise a typed exception once exceeded
chat.chat("Do the multi-step research task", max_tool_calls=20)
# -> raises ctl.ToolCallLimitExceeded(calls_made=20, last_request=...)
# Alternative: budget-based, building on existing get_cost()/get_tokens()
# (chatlas/_tokens.py) rather than duplicating pricing/accounting logic
chat.chat("...", max_cost=0.50) # USD, per get_cost()
Candidate implementation: thread an optional counter through _chat_impl/_chat_impl_async's while loop — increment per tool-call round (or per individual ContentToolRequest, since one assistant turn can request several tools at once) and check against max_tool_calls before invoking the next round; for a cost/token budget, call self.get_cost()/self.get_tokens() (already turn-aware, in chatlas/_chat.py/chatlas/_tokens.py) after each round instead of re-deriving usage math. On breach, raise a new typed exception (e.g. ToolCallLimitExceeded/UsageLimitExceeded, alongside ToolRejectError already defined in chatlas/_tools.py) carrying enough state to inspect what was pending — mirroring the ellmer commenter's point that a bare cap without a "what was happening when it fired" record just defers the debugging work. Should also discuss whether to instead gracefully stop, returning the partial Chat/ChatResponse with the last (tool-less) assistant turn intact, since a hard exception mid-stream can discard an otherwise-good partial answer — Pydantic AI's UsageLimitExceeded is a hard raise, but the ellmer thread leans toward wanting a resumable/inspectable stop.
Alternatives / prior art
- Pydantic AI:
UsageLimits(request_limit=, tool_calls_limit=)→UsageLimitExceeded(https://ai.pydantic.dev/agents/#usage-limits). - ellmer: tidyverse/ellmer#958 "Token Budget / Cap for Agentic Tool Call Loops" (open) — same ask, not yet implemented upstream either, so this would be a chance to land it first and propose the design back.
- Related, not duplicate: posit-dev/chatlas#8 "Make it easier to impose limits on conversation size" is about bounding context size (trimming/summarizing history so a long-running chat doesn't blow the context window); this issue is about bounding spend/loop iterations within a single
.chat()tool-calling round-trip. The two may end up sharing aLimits-style config object onChat, but they're solving different problems (context growth vs. runaway agentic loops) and #8 doesn't mention tool calls.
Open questions
- Per-
.chat()-call kwarg (max_tool_calls=), aChat.__init__/session-level setting, or both (session default overridable per call)? - Hard-raise vs. graceful partial stop — and if graceful, what does
ChatResponselook like when the loop was cut short mid-tool-round? - Should
max_costreuseget_price_info()/get_token_cost()fromchatlas/_tokens.pyas-is, given pricing data there is "a rough estimate" perChat.get_cost()'s own docstring, or should a cost-based limit come with a disclaimer that it's necessarily approximate? - Worth a soft-threshold warning message injected into the conversation (per the ellmer request) so the model can wrap up gracefully before the hard cap fires, rather than only supporting a hard cutoff?
Drafted from a competitive review of llm / Pydantic AI / LangChain / LiteLLM (July 2026); filed via Claude Code on behalf of @cpsievert.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading Chat._chat_impl() and _chat_impl_async() in chatlas/_chat.py, focusing on the while user_turn_result loop and tool invocation flow. Review chatlas/_tokens.py and chatlas/_tools.py for existing usage accounting and exception patterns. The work is done when the project has an agreed, tested design for tool-call or budget limits, breach state, and hard-stop versus graceful-stop behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- ai, backend-api-design
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 35/100