Skip to content

86: Add default limit for tools completions #87

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 31 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
e7f7a0f
feat: add limit to tools calls
rhys117 Apr 1, 2025
47c8742
docs: add docs for removing limit
rhys117 Apr 1, 2025
190e9de
docs: Fix docs
rhys117 Apr 1, 2025
fe837de
docs: improve doc - more accurate & organisation
rhys117 Apr 1, 2025
0f7ce26
bug: reset tools calls for each new 'ask' call
rhys117 Apr 1, 2025
6b807cf
bug: move error so tool result is still added
rhys117 Apr 2, 2025
df12db9
merge: main
rhys117 Apr 3, 2025
3af500d
chore: rework approach to rely on completions instead of tool calls
rhys117 Apr 4, 2025
39894a8
bug: fix attr_reader declaration to match number_of_tool_completions
rhys117 Apr 4, 2025
4718e2d
test: add better example
rhys117 Apr 7, 2025
7885c4b
docs: correct docs after changing naming/strategy
rhys117 Apr 7, 2025
585af65
merge: Merge branch 'main' into max-tool-calls
rhys117 Apr 7, 2025
df26988
docs: add default to docs
rhys117 Apr 7, 2025
19c0cd1
chore: rename error to match new naming
rhys117 Apr 7, 2025
3ad25f4
Merge branch 'main' into max-tool-calls
rhys117 Apr 24, 2025
d94d6dd
chore: reorder configuration accessor and add comment
rhys117 Apr 24, 2025
71dba53
style: cops - disable / fix
rhys117 Apr 24, 2025
6455f08
test: add spec for configured limit
rhys117 Apr 24, 2025
5a44bff
docs: adjust tools docs
rhys117 Apr 24, 2025
17b55cc
merge: main
rhys117 May 3, 2025
d09ce92
bug: ensure with_max_tool_completions available through acts_as helpers
rhys117 May 3, 2025
69af539
Merge branch 'main' into max-tool-calls
rhys117 Jun 11, 2025
018cc3b
deps: remove faker gem
rhys117 Jun 11, 2025
4cb6765
chore: use existing context instead of with_max_tool_completions
rhys117 Jun 11, 2025
6cb8ec3
test: adjust spec for context use
rhys117 Jun 11, 2025
4cdb924
chore: rename to max_tool_llm_calls
rhys117 Jun 12, 2025
f8fc8a5
docs: minor doc correction after rename
rhys117 Jun 12, 2025
731fcc6
chore: rename error class
rhys117 Jun 12, 2025
092ff2c
test: ensure spec cases for openrouter, openai, anthropic passing
rhys117 Jun 12, 2025
a0a1fa8
bug: fix +1 issue for llm tool lomts
rhys117 Jun 12, 2025
397438e
docs: improve tool docs for max tool llm calls
rhys117 Jun 12, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ group :development do
gem 'bundler', '>= 2.0'
gem 'codecov'
gem 'dotenv'
gem 'faker'
gem 'ferrum'
gem 'irb'
gem 'nokogiri'
Expand Down
39 changes: 39 additions & 0 deletions docs/guides/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,44 @@ Proper error handling within your `execute` method is crucial.

See the [Error Handling Guide]({% link guides/error-handling.md %}#handling-errors-within-tools) for more discussion.

## Maximum Tool Completion Limiting

When including tools it is important to consider if the response could trigger unintended recursive calls to the provider. RubyLLM provides built-in protection by providing a default limit of 25, which can be overridden or turned off entirely.

This can be performed on a per chat basis or provided in the global configuration.

```ruby
# Set a maximum number of tool completions per instantiated chat object
chat = RubyLLM.chat.with_max_tool_completions(5)
chat.ask "Question that triggers tools loop"
# => `execute_tool': Tool completions limit reached: 5 (RubyLLM::ToolCallCompletionsReachedError)
```

If you wish to remove this safe-guard you can set the max_tool_completions to `nil`.
```ruby
chat = RubyLLM.chat.with_max_tool_completions(nil)
chat.ask "Question that triggers tools loop"
# Loops until you've used all your credit...
```

### Global Configuration

You can set a default maximum tool completion limit for all chats through the global configuration:

```ruby
RubyLLM.configure do |config|
# Default is 25 calls per conversation
config.max_tool_completions = 10 # Set a more conservative limit
end
```

This setting can still be overridden per-chat when needed:

```ruby
# Override the global setting for this specific chat
chat = RubyLLM.chat.with_max_tool_completions(5)
```

## Security Considerations

{: .warning }
Expand All @@ -204,6 +242,7 @@ Treat any arguments passed to your `execute` method as potentially untrusted use
* **NEVER** use methods like `eval`, `system`, `send`, or direct SQL interpolation with raw arguments from the AI.
* **Validate and Sanitize:** Always validate parameter types, ranges, formats, and allowed values. Sanitize strings to prevent injection attacks if they are used in database queries or system commands (though ideally, avoid direct system commands).
* **Principle of Least Privilege:** Ensure the code within `execute` only has access to the resources it absolutely needs.
* **Cost-based Denial of Service:** Ensure protection against malicious input or usage, particularly when used in conjunction with tool completions if you remove the default limit

## Next Steps

Expand Down
5 changes: 5 additions & 0 deletions lib/ruby_llm/active_record/acts_as.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ def with_tools(...)
self
end

def with_max_tool_completions(...)
to_llm.with_max_tool_completions(...)
self
end

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now have configuration Contexts so we don't need the per-chat max tool completions method.

def with_model(...)
to_llm.with_model(...)
self
Expand Down
24 changes: 22 additions & 2 deletions lib/ruby_llm/chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ module RubyLLM
class Chat # rubocop:disable Metrics/ClassLength
include Enumerable

attr_reader :model, :messages, :tools
attr_reader :model, :messages, :tools, :number_of_tool_completions
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to have it as attr_reader.


def initialize(model: nil, provider: nil, assume_model_exists: false, context: nil) # rubocop:disable Metrics/MethodLength
if assume_model_exists && !provider
Expand All @@ -29,9 +29,13 @@ def initialize(model: nil, provider: nil, assume_model_exists: false, context: n
new_message: nil,
end_message: nil
}
@max_tool_completions = config.max_tool_completions
@number_of_tool_completions = 0
end

def ask(message = nil, with: {}, &)
@number_of_tool_completions = 0

add_message role: :user, content: Content.new(message, with)
complete(&)
end
Expand Down Expand Up @@ -60,6 +64,11 @@ def with_tools(*tools)
self
end

def with_max_tool_completions(max_tool_completions)
@max_tool_completions = max_tool_completions
self
end

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now have configuration Contexts so we don't need the per-chat max tool completions method.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👌 Much nicer for overridding

def with_model(model_id, provider: nil, assume_exists: false)
@model, @provider = Models.resolve(model_id, provider:, assume_exists:)
self
Expand Down Expand Up @@ -112,14 +121,19 @@ def add_message(message_or_attributes)

private

def handle_tool_calls(response, &)
def handle_tool_calls(response, &) # rubocop:disable Metrics/MethodLength
response.tool_calls.each_value do |tool_call|
@on[:new_message]&.call
result = execute_tool tool_call
message = add_tool_result tool_call.id, result
@on[:end_message]&.call(message)
end

if max_tool_completions_reached?
raise ToolCallCompletionsLimitReachedError, "Tool completions limit reached: #{@max_tool_completions}"
end

@number_of_tool_completions += 1
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be at the top of the method? Say max_tool_completions is 0, that would mean that we should process 0 tool completions, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review @crmne, you're right, this should have been before the max_tool_completions_reached? check. I've amended this now along with your other feedback.

I haven't manually tested the new changes yet, but the test cases are passing for the providers I have keys for.

Please let me know what you think.

complete(&)
end

Expand All @@ -136,5 +150,11 @@ def add_tool_result(tool_use_id, result)
tool_call_id: tool_use_id
)
end

def max_tool_completions_reached?
return false unless @max_tool_completions

@number_of_tool_completions >= @max_tool_completions
end
end
end
3 changes: 3 additions & 0 deletions lib/ruby_llm/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ class Configuration
:bedrock_session_token,
:openrouter_api_key,
:ollama_api_base,
# Default tool configuration
:max_tool_completions,
# Default models
:default_model,
:default_embedding_model,
Expand All @@ -45,6 +47,7 @@ def initialize
@default_model = 'gpt-4.1-nano'
@default_embedding_model = 'text-embedding-3-small'
@default_image_model = 'dall-e-3'
@max_tool_completions = 25
end

def inspect # rubocop:disable Metrics/MethodLength
Expand Down
1 change: 1 addition & 0 deletions lib/ruby_llm/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class ConfigurationError < StandardError; end
class InvalidRoleError < StandardError; end
class ModelNotFoundError < StandardError; end
class UnsupportedFunctionsError < StandardError; end
class ToolCallCompletionsLimitReachedError < StandardError; end

# Error classes for different HTTP status codes
class BadRequestError < Error; end
Expand Down
Loading