Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit 1dde82e

Browse files
SamSaffronnattsw
andauthored
FEATURE: allow specifying tool use none in completion prompt
This PR adds support for disabling further tool calls by setting tool_choice to :none across all supported LLM providers: - OpenAI: Uses "none" tool_choice parameter - Anthropic: Uses {type: "none"} and adds a prefill message to prevent confusion - Gemini: Sets function_calling_config mode to "NONE" - AWS Bedrock: Doesn't natively support tool disabling, so adds a prefill message We previously used to disable tool calls by simply removing tool definitions, but this would cause errors with some providers. This implementation uses the supported method appropriate for each provider while providing a fallback for Bedrock. Co-authored-by: Natalie Tay <[email protected]> * remove stray puts * cleaner chain breaker for last tool call (works in thinking) remove unused code * improve test --------- Co-authored-by: Natalie Tay <[email protected]>
1 parent 50e1bc7 commit 1dde82e

File tree

12 files changed

+410
-26
lines changed

12 files changed

+410
-26
lines changed

lib/ai_bot/bot.rb

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ class Bot
66
attr_reader :model
77

88
BOT_NOT_FOUND = Class.new(StandardError)
9+
910
# the future is agentic, allow for more turns
1011
MAX_COMPLETIONS = 8
12+
1113
# limit is arbitrary, but 5 which was used in the past was too low
1214
MAX_TOOLS = 20
1315

@@ -71,6 +73,8 @@ def get_updated_title(conversation_context, post, user)
7173
end
7274

7375
def force_tool_if_needed(prompt, context)
76+
return if prompt.tool_choice == :none
77+
7478
context[:chosen_tools] ||= []
7579
forced_tools = persona.force_tool_use.map { |tool| tool.name }
7680
force_tool = forced_tools.find { |name| !context[:chosen_tools].include?(name) }
@@ -105,7 +109,7 @@ def reply(context, &update_blk)
105109
needs_newlines = false
106110
tools_ran = 0
107111

108-
while total_completions <= MAX_COMPLETIONS && ongoing_chain
112+
while total_completions < MAX_COMPLETIONS && ongoing_chain
109113
tool_found = false
110114
force_tool_if_needed(prompt, context)
111115

@@ -202,8 +206,8 @@ def reply(context, &update_blk)
202206

203207
total_completions += 1
204208

205-
# do not allow tools when we are at the end of a chain (total_completions == MAX_COMPLETIONS)
206-
prompt.tools = [] if total_completions == MAX_COMPLETIONS
209+
# do not allow tools when we are at the end of a chain (total_completions == MAX_COMPLETIONS - 1)
210+
prompt.tool_choice = :none if total_completions == MAX_COMPLETIONS - 1
207211
end
208212

209213
embed_thinking(raw_context)

lib/completions/dialects/dialect.rb

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,6 @@ def initialize(generic_prompt, llm_model, opts: {})
4646

4747
VALID_ID_REGEX = /\A[a-zA-Z0-9_]+\z/
4848

49-
def can_end_with_assistant_msg?
50-
false
51-
end
52-
5349
def native_tool_support?
5450
false
5551
end
@@ -66,16 +62,58 @@ def tool_choice
6662
prompt.tool_choice
6763
end
6864

65+
def self.no_more_tool_calls_text
66+
# note, Anthropic must never prefill with an ending whitespace
67+
"I WILL NOT USE TOOLS IN THIS REPLY, user expressed they wanted to stop using tool calls.\nHere is the best, complete, answer I can come up with given the information I have."
68+
end
69+
70+
def self.no_more_tool_calls_text_user
71+
"DO NOT USE TOOLS IN YOUR REPLY. Return the best answer you can given the information I supplied you."
72+
end
73+
74+
def no_more_tool_calls_text
75+
self.class.no_more_tool_calls_text
76+
end
77+
78+
def no_more_tool_calls_text_user
79+
self.class.no_more_tool_calls_text_user
80+
end
81+
6982
def translate
70-
messages = prompt.messages
83+
messages = trim_messages(prompt.messages)
84+
last_message = messages.last
85+
inject_done_on_last_tool_call = false
7186

72-
# Some models use an assistant msg to improve long-context responses.
73-
if messages.last[:type] == :model && can_end_with_assistant_msg?
74-
messages = messages.dup
75-
messages.pop
87+
if !native_tool_support? && last_message && last_message[:type].to_sym == :tool &&
88+
prompt.tool_choice == :none
89+
inject_done_on_last_tool_call = true
7690
end
7791

78-
trim_messages(messages).map { |msg| send("#{msg[:type]}_msg", msg) }.compact
92+
translated =
93+
messages
94+
.map do |msg|
95+
case msg[:type].to_sym
96+
when :system
97+
system_msg(msg)
98+
when :user
99+
user_msg(msg)
100+
when :model
101+
model_msg(msg)
102+
when :tool
103+
if inject_done_on_last_tool_call && msg == last_message
104+
tools_dialect.inject_done { tool_msg(msg) }
105+
else
106+
tool_msg(msg)
107+
end
108+
when :tool_call
109+
tool_call_msg(msg)
110+
else
111+
raise ArgumentError, "Unknown message type: #{msg[:type]}"
112+
end
113+
end
114+
.compact
115+
116+
translated
79117
end
80118

81119
def conversation_context

lib/completions/dialects/xml_tools.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,11 @@ def instructions
5454
end
5555
end
5656

57+
DONE_MESSAGE =
58+
"Regardless of what you think, REPLY IMMEDIATELY, WITHOUT MAKING ANY FURTHER TOOL CALLS, YOU ARE OUT OF TOOL CALL QUOTA!"
59+
5760
def from_raw_tool(raw_message)
58-
(<<~TEXT).strip
61+
result = (<<~TEXT).strip
5962
<function_results>
6063
<result>
6164
<tool_name>#{raw_message[:name] || raw_message[:id]}</tool_name>
@@ -65,6 +68,12 @@ def from_raw_tool(raw_message)
6568
</result>
6669
</function_results>
6770
TEXT
71+
72+
if @injecting_done
73+
"#{result}\n\n#{DONE_MESSAGE}"
74+
else
75+
result
76+
end
6877
end
6978

7079
def from_raw_tool_call(raw_message)
@@ -86,6 +95,13 @@ def from_raw_tool_call(raw_message)
8695
TEXT
8796
end
8897

98+
def inject_done(&blk)
99+
@injecting_done = true
100+
blk.call
101+
ensure
102+
@injecting_done = false
103+
end
104+
89105
private
90106

91107
attr_reader :raw_tools

lib/completions/endpoints/anthropic.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,18 @@ def prepare_payload(prompt, model_params, dialect)
9595
if prompt.has_tools?
9696
payload[:tools] = prompt.tools
9797
if dialect.tool_choice.present?
98-
payload[:tool_choice] = { type: "tool", name: dialect.tool_choice }
98+
if dialect.tool_choice == :none
99+
payload[:tool_choice] = { type: "none" }
100+
101+
# prefill prompt to nudge LLM to generate a response that is useful.
102+
# without this LLM (even 3.7) can get confused and start text preambles for a tool calls.
103+
payload[:messages] << {
104+
role: "assistant",
105+
content: dialect.no_more_tool_calls_text,
106+
}
107+
else
108+
payload[:tool_choice] = { type: "tool", name: prompt.tool_choice }
109+
end
99110
end
100111
end
101112

lib/completions/endpoints/aws_bedrock.rb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,19 @@ def prepare_payload(prompt, model_params, dialect)
122122
if prompt.has_tools?
123123
payload[:tools] = prompt.tools
124124
if dialect.tool_choice.present?
125-
payload[:tool_choice] = { type: "tool", name: dialect.tool_choice }
125+
if dialect.tool_choice == :none
126+
# not supported on bedrock as of 2025-03-24
127+
# retest in 6 months
128+
# payload[:tool_choice] = { type: "none" }
129+
130+
# prefill prompt to nudge LLM to generate a response that is useful, instead of trying to call a tool
131+
payload[:messages] << {
132+
role: "assistant",
133+
content: dialect.no_more_tool_calls_text,
134+
}
135+
else
136+
payload[:tool_choice] = { type: "tool", name: prompt.tool_choice }
137+
end
126138
end
127139
end
128140
elsif dialect.is_a?(DiscourseAi::Completions::Dialects::Nova)

lib/completions/endpoints/gemini.rb

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,14 @@ def prepare_payload(prompt, model_params, dialect)
7272

7373
function_calling_config = { mode: "AUTO" }
7474
if dialect.tool_choice.present?
75-
function_calling_config = {
76-
mode: "ANY",
77-
allowed_function_names: [dialect.tool_choice],
78-
}
75+
if dialect.tool_choice == :none
76+
function_calling_config = { mode: "NONE" }
77+
else
78+
function_calling_config = {
79+
mode: "ANY",
80+
allowed_function_names: [dialect.tool_choice],
81+
}
82+
end
7983
end
8084

8185
payload[:tool_config] = { function_calling_config: function_calling_config }

lib/completions/endpoints/open_ai.rb

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,16 @@ def prepare_payload(prompt, model_params, dialect)
9292
if dialect.tools.present?
9393
payload[:tools] = dialect.tools
9494
if dialect.tool_choice.present?
95-
payload[:tool_choice] = {
96-
type: "function",
97-
function: {
98-
name: dialect.tool_choice,
99-
},
100-
}
95+
if dialect.tool_choice == :none
96+
payload[:tool_choice] = "none"
97+
else
98+
payload[:tool_choice] = {
99+
type: "function",
100+
function: {
101+
name: dialect.tool_choice,
102+
},
103+
}
104+
end
101105
end
102106
end
103107
end

spec/lib/completions/dialects/dialect_spec.rb

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ def trim(messages)
77
trim_messages(messages)
88
end
99

10+
def system_msg(msg)
11+
msg
12+
end
13+
14+
def user_msg(msg)
15+
msg
16+
end
17+
18+
def model_msg(msg)
19+
msg
20+
end
21+
1022
def tokenizer
1123
DiscourseAi::Tokenizer::OpenAiTokenizer
1224
end
@@ -15,6 +27,57 @@ def tokenizer
1527
RSpec.describe DiscourseAi::Completions::Dialects::Dialect do
1628
fab!(:llm_model)
1729

30+
describe "#translate" do
31+
let(:five_token_msg) { "This represents five tokens." }
32+
let(:tools) do
33+
[
34+
{
35+
name: "echo",
36+
description: "echo a string",
37+
parameters: [
38+
{ name: "text", type: "string", description: "string to echo", required: true },
39+
],
40+
},
41+
]
42+
end
43+
44+
it "injects done message when tool_choice is :none and last message follows tool pattern" do
45+
tool_call_prompt = { name: "echo", arguments: { text: "test message" } }
46+
47+
prompt = DiscourseAi::Completions::Prompt.new("System instructions", tools: tools)
48+
prompt.push(type: :user, content: "echo test message")
49+
prompt.push(type: :tool_call, content: tool_call_prompt.to_json, id: "123", name: "echo")
50+
prompt.push(type: :tool, content: "test message".to_json, name: "echo", id: "123")
51+
prompt.tool_choice = :none
52+
53+
dialect = TestDialect.new(prompt, llm_model)
54+
dialect.max_prompt_tokens = 100 # Set high enough to avoid trimming
55+
56+
translated = dialect.translate
57+
58+
expect(translated).to eq(
59+
[
60+
{ type: :system, content: "System instructions" },
61+
{ type: :user, content: "echo test message" },
62+
{
63+
type: :tool_call,
64+
content:
65+
"<function_calls>\n<invoke>\n<tool_name>echo</tool_name>\n<parameters>\n<text>test message</text>\n</parameters>\n</invoke>\n</function_calls>",
66+
id: "123",
67+
name: "echo",
68+
},
69+
{
70+
type: :tool,
71+
id: "123",
72+
name: "echo",
73+
content:
74+
"<function_results>\n<result>\n<tool_name>echo</tool_name>\n<json>\n\"test message\"\n</json>\n</result>\n</function_results>\n\n#{::DiscourseAi::Completions::Dialects::XmlTools::DONE_MESSAGE}",
75+
},
76+
],
77+
)
78+
end
79+
end
80+
1881
describe "#trim_messages" do
1982
let(:five_token_msg) { "This represents five tokens." }
2083

spec/lib/completions/endpoints/anthropic_spec.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,4 +714,59 @@
714714
expect(parsed_body[:max_tokens]).to eq(500)
715715
end
716716
end
717+
718+
describe "disabled tool use" do
719+
it "can properly disable tool use with :none" do
720+
prompt =
721+
DiscourseAi::Completions::Prompt.new(
722+
"You are a bot",
723+
messages: [type: :user, id: "user1", content: "don't use any tools please"],
724+
tools: [echo_tool],
725+
tool_choice: :none,
726+
)
727+
728+
response_body = {
729+
id: "msg_01RdJkxCbsEj9VFyFYAkfy2S",
730+
type: "message",
731+
role: "assistant",
732+
model: "claude-3-haiku-20240307",
733+
content: [
734+
{ type: "text", text: "I won't use any tools. Here's a direct response instead." },
735+
],
736+
stop_reason: "end_turn",
737+
stop_sequence: nil,
738+
usage: {
739+
input_tokens: 345,
740+
output_tokens: 65,
741+
},
742+
}.to_json
743+
744+
parsed_body = nil
745+
stub_request(:post, url).with(
746+
body:
747+
proc do |req_body|
748+
parsed_body = JSON.parse(req_body, symbolize_names: true)
749+
true
750+
end,
751+
).to_return(status: 200, body: response_body)
752+
753+
result = llm.generate(prompt, user: Discourse.system_user)
754+
755+
# Verify that tool_choice is set to { type: "none" }
756+
expect(parsed_body[:tool_choice]).to eq({ type: "none" })
757+
758+
# Verify that an assistant message with no_more_tool_calls_text was added
759+
messages = parsed_body[:messages]
760+
expect(messages.length).to eq(2) # user message + added assistant message
761+
762+
last_message = messages.last
763+
expect(last_message[:role]).to eq("assistant")
764+
765+
expect(last_message[:content]).to eq(
766+
DiscourseAi::Completions::Dialects::Dialect.no_more_tool_calls_text,
767+
)
768+
769+
expect(result).to eq("I won't use any tools. Here's a direct response instead.")
770+
end
771+
end
717772
end

0 commit comments

Comments
 (0)