diff --git a/docs/_core_features/agents.md b/docs/_core_features/agents.md index 0a4889b1d..4a6eb8ebb 100644 --- a/docs/_core_features/agents.md +++ b/docs/_core_features/agents.md @@ -116,6 +116,19 @@ class CriticAgent < RubyLLM::Agent end ``` +`tools` accepts the same `choice:` and `calls:` options as `with_tools`, forwarding them to the underlying chat: + +```ruby +class WeatherAgent < RubyLLM::Agent + tools Weather, Calculator, choice: :required +end + +# Block form works too: +class WeatherAgent < RubyLLM::Agent + tools(choice: :required) { [Weather.new, Calculator.new] } +end +``` + ## Runtime Context and Inputs Agents support runtime-evaluated values using blocks and lambdas. diff --git a/lib/ruby_llm/agent.rb b/lib/ruby_llm/agent.rb index 048bf77bb..b3189d7c4 100644 --- a/lib/ruby_llm/agent.rb +++ b/lib/ruby_llm/agent.rb @@ -16,6 +16,7 @@ def inherited(subclass) super subclass.instance_variable_set(:@chat_kwargs, (@chat_kwargs || {}).dup) subclass.instance_variable_set(:@tools, (@tools || []).dup) + subclass.instance_variable_set(:@tool_options, (@tool_options || {}).dup) subclass.instance_variable_set(:@instructions, @instructions) subclass.instance_variable_set(:@temperature, @temperature) subclass.instance_variable_set(:@thinking, @thinking) @@ -32,10 +33,15 @@ def model(model_id = nil, **options) @chat_kwargs = options end - def tools(*tools, &block) - return @tools || [] if tools.empty? && !block_given? + def tools(*tools, choice: nil, calls: nil, &block) + return @tools || [] if tools.empty? && !block_given? && choice.nil? && calls.nil? @tools = block_given? ? block : tools.flatten + @tool_options = { choice:, calls: }.compact + end + + def tool_options + @tool_options || {} end def instructions(text = nil, **prompt_locals, &block) @@ -208,7 +214,9 @@ def apply_instructions(chat_object, runtime, inputs:, persist:) def apply_tools(llm_chat, runtime) tools_to_apply = Array(evaluate(tools, runtime)) - llm_chat.with_tools(*tools_to_apply) unless tools_to_apply.empty? + return if tools_to_apply.empty? + + llm_chat.with_tools(*tools_to_apply, **tool_options) end def apply_temperature(llm_chat) diff --git a/spec/ruby_llm/agent_spec.rb b/spec/ruby_llm/agent_spec.rb index 3502aa2f8..44a358905 100644 --- a/spec/ruby_llm/agent_spec.rb +++ b/spec/ruby_llm/agent_spec.rb @@ -36,6 +36,83 @@ def name = 'echo_tool' expect(chat.messages.first.content).to eq('RubyLLM::Chat') end + it 'forwards choice: from the tools macro positional form to with_tools' do + tool_class = Class.new(RubyLLM::Tool) do + def name = 'echo_tool' + end + + agent_class = Class.new(RubyLLM::Agent) do + model 'gpt-4.1-nano' + tools tool_class, choice: :required + end + + chat = agent_class.chat + + expect(chat.tools.keys).to include(:echo_tool) + expect(chat.tool_prefs[:choice]).to eq(:required) + end + + it 'forwards choice: from the tools macro block form to with_tools' do + tool_class = Class.new(RubyLLM::Tool) do + def name = 'echo_tool' + end + + agent_class = Class.new(RubyLLM::Agent) do + model 'gpt-4.1-nano' + tools(choice: :required) { [tool_class.new] } + end + + chat = agent_class.chat + + expect(chat.tools.keys).to include(:echo_tool) + expect(chat.tool_prefs[:choice]).to eq(:required) + end + + it 'forwards calls: from the tools macro to with_tools' do + tool_class = Class.new(RubyLLM::Tool) do + def name = 'echo_tool' + end + + agent_class = Class.new(RubyLLM::Agent) do + model 'gpt-4.1-nano' + tools tool_class, calls: :one + end + + chat = agent_class.chat + + expect(chat.tool_prefs[:calls]).to eq(:one) + end + + it 'keeps the zero-arg tools reader returning the configured tool list' do + tool_class = Class.new(RubyLLM::Tool) do + def name = 'echo_tool' + end + + agent_class = Class.new(RubyLLM::Agent) do + model 'gpt-4.1-nano' + tools tool_class, choice: :required + end + + expect(agent_class.tools).to eq([tool_class]) + end + + it 'leaves tool prefs unset when no options are given' do + tool_class = Class.new(RubyLLM::Tool) do + def name = 'echo_tool' + end + + agent_class = Class.new(RubyLLM::Agent) do + model 'gpt-4.1-nano' + tools tool_class + end + + chat = agent_class.chat + + expect(chat.tools.keys).to include(:echo_tool) + expect(chat.tool_prefs[:choice]).to be_nil + expect(chat.tool_prefs[:calls]).to be_nil + end + it 'raises when instructions default prompt is missing' do agent_class = Class.new(RubyLLM::Agent) do model 'gpt-4.1-nano'