posit-dev / posit-dev/chatlas

feat: usage guards (max tool calls / cost budget) for the tool-calling loop

Open
#359 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

ai-triage:needs-review enhancement
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 a Limits-style config object on Chat, 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=), a Chat.__init__/session-level setting, or both (session default overridable per call)?
  • Hard-raise vs. graceful partial stop — and if graceful, what does ChatResponse look like when the loop was cut short mid-tool-round?
  • Should max_cost reuse get_price_info()/get_token_cost() from chatlas/_tokens.py as-is, given pricing data there is "a rough estimate" per Chat.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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.