Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
10 changes: 7 additions & 3 deletions lib/ai_bot/bot.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ class Bot
attr_reader :model

BOT_NOT_FOUND = Class.new(StandardError)

# the future is agentic, allow for more turns
MAX_COMPLETIONS = 8

# limit is arbitrary, but 5 which was used in the past was too low
MAX_TOOLS = 20

Expand Down Expand Up @@ -71,6 +73,8 @@ def get_updated_title(conversation_context, post, user)
end

def force_tool_if_needed(prompt, context)
return if prompt.tool_choice == :none

context[:chosen_tools] ||= []
forced_tools = persona.force_tool_use.map { |tool| tool.name }
force_tool = forced_tools.find { |name| !context[:chosen_tools].include?(name) }
Expand Down Expand Up @@ -105,7 +109,7 @@ def reply(context, &update_blk)
needs_newlines = false
tools_ran = 0

while total_completions <= MAX_COMPLETIONS && ongoing_chain
while total_completions < MAX_COMPLETIONS && ongoing_chain
tool_found = false
force_tool_if_needed(prompt, context)

Expand Down Expand Up @@ -202,8 +206,8 @@ def reply(context, &update_blk)

total_completions += 1

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

embed_thinking(raw_context)
Expand Down
27 changes: 26 additions & 1 deletion lib/completions/dialects/dialect.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ def tool_choice
prompt.tool_choice
end

def self.no_more_tool_calls_text
# note, Anthropic must never prefill with an ending whitespace
"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."
end

def no_more_tool_calls_text
self.class.no_more_tool_calls_text
end

def translate
messages = prompt.messages

Expand All @@ -75,7 +84,23 @@ def translate
messages.pop
end

trim_messages(messages).map { |msg| send("#{msg[:type]}_msg", msg) }.compact
translated = trim_messages(messages).map { |msg| send("#{msg[:type]}_msg", msg) }.compact

if !native_tool_support?
if prompt.tools.present? && prompt.tool_choice.present?
if prompt.tool_choice == :none
translated << model_msg(role: "assistant", content: no_more_tool_calls_text)
else
translated << model_msg(
role: "assistant",
content:
"User required I call the tool: #{prompt.tool_choice} I will makes sure I use it now:",
)
end
end
end

translated
end

def conversation_context
Expand Down
14 changes: 13 additions & 1 deletion lib/completions/endpoints/anthropic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,22 @@ def prepare_payload(prompt, model_params, dialect)
if prompt.has_tools?
payload[:tools] = prompt.tools
if dialect.tool_choice.present?
payload[:tool_choice] = { type: "tool", name: dialect.tool_choice }
if dialect.tool_choice == :none
payload[:tool_choice] = { type: "none" }

# prefill prompt to nudge LLM to generate a response that is useful.
# without this LLM (even 3.7) can get confused and start text preambles for a tool calls.
payload[:messages] << {
role: "assistant",
content: dialect.no_more_tool_calls_text,
}
else
payload[:tool_choice] = { type: "tool", name: prompt.tool_choice }
end
end
end

puts "tool_choice: #{payload[:tool_choice]} - #{dialect.tool_choice}"
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you need this?

Copy link
Member Author

Choose a reason for hiding this comment

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

nope leftover ... oops ... will clean up

payload
end

Expand Down
14 changes: 13 additions & 1 deletion lib/completions/endpoints/aws_bedrock.rb
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,19 @@ def prepare_payload(prompt, model_params, dialect)
if prompt.has_tools?
payload[:tools] = prompt.tools
if dialect.tool_choice.present?
payload[:tool_choice] = { type: "tool", name: dialect.tool_choice }
if dialect.tool_choice == :none
# not supported on bedrock as of 2025-03-24
# retest in 6 months
# payload[:tool_choice] = { type: "none" }

# prefill prompt to nudge LLM to generate a response that is useful, instead of trying to call a tool
payload[:messages] << {
role: "assistant",
content: dialect.no_more_tool_calls_text,
}
else
payload[:tool_choice] = { type: "tool", name: prompt.tool_choice }
end
end
end
elsif dialect.is_a?(DiscourseAi::Completions::Dialects::Nova)
Expand Down
12 changes: 8 additions & 4 deletions lib/completions/endpoints/gemini.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,14 @@ def prepare_payload(prompt, model_params, dialect)

function_calling_config = { mode: "AUTO" }
if dialect.tool_choice.present?
function_calling_config = {
mode: "ANY",
allowed_function_names: [dialect.tool_choice],
}
if dialect.tool_choice == :none
function_calling_config = { mode: "NONE" }
else
function_calling_config = {
mode: "ANY",
allowed_function_names: [dialect.tool_choice],
}
end
end

payload[:tool_config] = { function_calling_config: function_calling_config }
Expand Down
16 changes: 10 additions & 6 deletions lib/completions/endpoints/open_ai.rb
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,16 @@ def prepare_payload(prompt, model_params, dialect)
if dialect.tools.present?
payload[:tools] = dialect.tools
if dialect.tool_choice.present?
payload[:tool_choice] = {
type: "function",
function: {
name: dialect.tool_choice,
},
}
if dialect.tool_choice == :none
payload[:tool_choice] = "none"
else
payload[:tool_choice] = {
type: "function",
function: {
name: dialect.tool_choice,
},
}
end
end
end
end
Expand Down
55 changes: 55 additions & 0 deletions spec/lib/completions/endpoints/anthropic_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -714,4 +714,59 @@
expect(parsed_body[:max_tokens]).to eq(500)
end
end

describe "disabled tool use" do
it "can properly disable tool use with :none" do
prompt =
DiscourseAi::Completions::Prompt.new(
"You are a bot",
messages: [type: :user, id: "user1", content: "don't use any tools please"],
tools: [echo_tool],
tool_choice: :none,
)

response_body = {
id: "msg_01RdJkxCbsEj9VFyFYAkfy2S",
type: "message",
role: "assistant",
model: "claude-3-haiku-20240307",
content: [
{ type: "text", text: "I won't use any tools. Here's a direct response instead." },
],
stop_reason: "end_turn",
stop_sequence: nil,
usage: {
input_tokens: 345,
output_tokens: 65,
},
}.to_json

parsed_body = nil
stub_request(:post, url).with(
body:
proc do |req_body|
parsed_body = JSON.parse(req_body, symbolize_names: true)
true
end,
).to_return(status: 200, body: response_body)

result = llm.generate(prompt, user: Discourse.system_user)

# Verify that tool_choice is set to { type: "none" }
expect(parsed_body[:tool_choice]).to eq({ type: "none" })

# Verify that an assistant message with no_more_tool_calls_text was added
messages = parsed_body[:messages]
expect(messages.length).to eq(2) # user message + added assistant message

last_message = messages.last
expect(last_message[:role]).to eq("assistant")

expect(last_message[:content]).to eq(
DiscourseAi::Completions::Dialects::Dialect.no_more_tool_calls_text,
)

expect(result).to eq("I won't use any tools. Here's a direct response instead.")
end
end
end
62 changes: 62 additions & 0 deletions spec/lib/completions/endpoints/aws_bedrock_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -484,4 +484,66 @@ def encode_message(message)
expect(request_body["max_tokens"]).to eq(500)
end
end

describe "disabled tool use" do
it "handles tool_choice: :none by adding a prefill message instead of using tool_choice param" do
proxy = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}")
request = nil

# Create a prompt with tool_choice: :none
prompt =
DiscourseAi::Completions::Prompt.new(
"You are a helpful assistant",
messages: [{ type: :user, content: "don't use any tools please" }],
tools: [
{
name: "echo",
description: "echo something",
parameters: [
{ name: "text", type: "string", description: "text to echo", required: true },
],
},
],
tool_choice: :none,
)

# Mock response from Bedrock
content = {
content: [text: "I won't use any tools. Here's a direct response instead."],
usage: {
input_tokens: 25,
output_tokens: 15,
},
}.to_json

stub_request(
:post,
"https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-sonnet-20240229-v1:0/invoke",
)
.with do |inner_request|
request = inner_request
true
end
.to_return(status: 200, body: content)

proxy.generate(prompt, user: user)

# Parse the request body
request_body = JSON.parse(request.body)

# Verify that tool_choice is NOT present (not supported in Bedrock)
expect(request_body).not_to have_key("tool_choice")

# Verify that an assistant message was added with no_more_tool_calls_text
messages = request_body["messages"]
expect(messages.length).to eq(2) # user message + added assistant message

last_message = messages.last
expect(last_message["role"]).to eq("assistant")

expect(last_message["content"]).to eq(
DiscourseAi::Completions::Dialects::Dialect.no_more_tool_calls_text,
)
end
end
end
56 changes: 56 additions & 0 deletions spec/lib/completions/endpoints/gemini_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -377,4 +377,60 @@ def tool_response

expect(output.join).to eq("Hello World Sam")
end

it "can properly disable tool use with :none" do
prompt = DiscourseAi::Completions::Prompt.new("Hello", tools: [echo_tool], tool_choice: :none)

response = gemini_mock.response("I won't use any tools").to_json

req_body = nil

llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}")
url = "#{model.url}:generateContent?key=123"

stub_request(:post, url).with(
body:
proc do |_req_body|
req_body = _req_body
true
end,
).to_return(status: 200, body: response)

response = llm.generate(prompt, user: user)

expect(response).to eq("I won't use any tools")

parsed = JSON.parse(req_body, symbolize_names: true)

# Verify that function_calling_config mode is set to "NONE"
expect(parsed[:tool_config]).to eq({ function_calling_config: { mode: "NONE" } })
end

it "can properly force specific tool use" do
prompt = DiscourseAi::Completions::Prompt.new("Hello", tools: [echo_tool], tool_choice: "echo")

response = gemini_mock.response("World").to_json

req_body = nil

llm = DiscourseAi::Completions::Llm.proxy("custom:#{model.id}")
url = "#{model.url}:generateContent?key=123"

stub_request(:post, url).with(
body:
proc do |_req_body|
req_body = _req_body
true
end,
).to_return(status: 200, body: response)

response = llm.generate(prompt, user: user)

parsed = JSON.parse(req_body, symbolize_names: true)

# Verify that function_calling_config is correctly set to ANY mode with the specified tool
expect(parsed[:tool_config]).to eq(
{ function_calling_config: { mode: "ANY", allowed_function_names: ["echo"] } },
)
end
end
Loading