discourse/plugins/discourse-ai/lib/ai_bot/playground.rb
Rafael dos Santos Silva bc39aacc3d
FEATURE: Provider-native built-in tools for agents (web search) (#40809)
Adds a fourth kind of agent tool: provider-native built-in tools that
the
LLM provider executes server-side, rather than tools Discourse runs and
feeds back. The first one is web search, supported on Gemini (Google
Search
grounding), OpenAI (web search via the Responses API) and Anthropic
(Claude
web search).

Native tools are stored on the agent's `tools` column with a `native-`
prefix, flow to the prompt as a separate `native_tools` list (never as
runnable Tool classes), and each provider dialect renders them into its
own
request payload. Response processors already ignore the server-side
tool/grounding blocks, so the bot loop never tries to execute them.

They are only selectable when the agent forces a default LLM whose
provider
supports the tool; this is enforced both in the editor UI (filtered by
the
selected LLM's `supported_native_tools`) and by server-side validation.

Also fixes the Gemini endpoint sending `function_calling_config` without
any
`function_declarations`, which the API rejects when only native tools
are
present.

---------

Co-authored-by: Sam Saffron <sam.saffron@gmail.com>
2026-06-16 14:37:51 -03:00

803 lines
28 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscourseAi
module AiBot
class Playground
BYPASS_AI_REPLY_CUSTOM_FIELD = "discourse_ai_bypass_ai_reply"
BOT_USER_PREF_ID_CUSTOM_FIELD = "discourse_ai_bot_user_pref_id"
# 10 minutes is enough for vast majority of cases
# there is a small chance that some reasoning models may take longer
MAX_STREAM_DELAY_SECONDS = 600
attr_reader :bot
# An abstraction to manage the bot and topic interactions.
# The bot will take care of completions while this class updates the topic title
# and stream replies.
def self.find_chat_agent(message, channel, user)
if channel.direct_message_channel?
AiAgent
.allowed_modalities(allow_chat_direct_messages: true)
.find do |p|
p[:user_id].in?(channel.allowed_user_ids) && (user.group_ids & p[:allowed_group_ids])
end
else
# let's defer on the parse if there is no @ in the message
if message.message.include?("@")
mentions = message.parsed_mentions.parsed_direct_mentions
if mentions.present?
AiAgent
.allowed_modalities(allow_chat_channel_mentions: true)
.find { |p| p[:username].in?(mentions) && (user.group_ids & p[:allowed_group_ids]) }
end
end
end
end
def self.schedule_chat_reply(message, channel, user, context)
return if !SiteSetting.ai_bot_enabled
all_chat =
AiAgent.allowed_modalities(
allow_chat_channel_mentions: true,
allow_chat_direct_messages: true,
)
return if all_chat.blank?
return if all_chat.any? { |m| m[:user_id] == user.id }
agent = find_chat_agent(message, channel, user)
return if !agent
post_ids = nil
post_ids = context.dig(:context, :post_ids) if context.is_a?(Hash)
::Jobs.enqueue(
:create_ai_chat_reply,
channel_id: channel.id,
message_id: message.id,
agent_id: agent[:id],
context_post_ids: post_ids,
)
end
def self.is_bot_user_id?(user_id)
# this will catch everything and avoid any feedback loops
# we could get feedback loops between say discobot and ai-bot or third party plugins
# and bots
user_id.to_i <= 0
end
def self.get_bot_user(post:, all_llm_users:, mentionables:)
bot_user = nil
if post.topic.private_message?
# this ensures that we reply using the correct llm
# 1. if we have a preferred llm user we use that
# 2. if we don't just take first topic allowed user
# 3. if we don't have that we take the first mentionable
bot_user = nil
if preferred_user =
all_llm_users.find { |id, username|
id == post.topic.custom_fields[BOT_USER_PREF_ID_CUSTOM_FIELD].to_i
}
bot_user = User.find_by(id: preferred_user[0])
end
bot_user ||=
post.topic.topic_allowed_users.where(user_id: all_llm_users.map(&:first)).first&.user
bot_user ||=
post
.topic
.topic_allowed_users
.where(user_id: mentionables.map { |m| m[:user_id] })
.first
&.user
end
bot_user
end
def self.schedule_reply(post)
return if is_bot_user_id?(post.user_id)
mentionables = nil
if post.topic.private_message?
mentionables = AiAgent.allowed_modalities(user: post.user, allow_personal_messages: true)
else
mentionables = AiAgent.allowed_modalities(user: post.user, allow_topic_mentions: true)
end
mentioned = nil
all_llm_users =
LlmModel
.where(id: LlmModel.enabled_chat_bot_ids)
.joins(:user)
.pluck("users.id", "users.username_lower")
bot_user =
get_bot_user(post: post, all_llm_users: all_llm_users, mentionables: mentionables)
mentions = nil
if mentionables.present? || (bot_user && post.topic.private_message?)
mentions = post.mentions.map(&:downcase)
# in case we are replying to a post by a bot
if post.reply_to_post_number && post.reply_to_post&.user
mentions << post.reply_to_post.user.username_lower
end
end
if mentionables.present?
mentioned = mentionables.find { |mentionable| mentions.include?(mentionable[:username]) }
# direct PM to mentionable
if !mentioned && bot_user
mentioned = mentionables.find { |mentionable| bot_user.id == mentionable[:user_id] }
end
# public topic so we need to use the agent user
bot_user ||= User.find_by(id: mentioned[:user_id]) if mentioned
end
if !mentioned && bot_user && post.reply_to_post_number && !post.reply_to_post.user&.bot?
# replying to a non-bot user
return
end
if bot_user
topic_agent_id = post.topic.custom_fields["ai_agent_id"]
topic_agent_id = topic_agent_id.to_i if topic_agent_id.present?
agent_id = mentioned&.dig(:id) || topic_agent_id
agent = nil
agent = DiscourseAi::Agents::Agent.find_by(user: post.user, id: agent_id.to_i) if agent_id
if !agent && (agent_name = post.topic.custom_fields["ai_agent"])
agent = DiscourseAi::Agents::Agent.find_by(user: post.user, name: agent_name)
end
# edge case, llm was mentioned in an ai agent conversation
if agent_id == topic_agent_id && post.topic.private_message? && agent &&
all_llm_users.present?
if !agent.force_default_llm && mentions.present?
mentioned_llm_user_id, _ =
all_llm_users.find { |id, username| mentions.include?(username) }
if mentioned_llm_user_id
bot_user = User.find_by(id: mentioned_llm_user_id) || bot_user
end
end
end
agent ||= DiscourseAi::Agents::General
bot_user = User.find(agent.user_id) if agent && agent.force_default_llm
bot = DiscourseAi::Agents::Bot.as(bot_user, agent: agent.new)
new(bot).update_playground_with(post)
end
end
def self.reply_to_post(
post:,
user: nil,
agent_id: nil,
whisper: nil,
add_user_to_pm: false,
stream_reply: false,
auto_set_title: false,
silent_mode: false,
feature_name: nil,
attributed_user: nil,
feature_context: nil
)
ai_agent = AiAgent.find_by(id: agent_id)
raise Discourse::InvalidParameters.new(:agent_id) if !ai_agent
agent_class = ai_agent.class_instance
agent = agent_class.new
bot_user = user || ai_agent.user
raise Discourse::InvalidParameters.new(:user) if bot_user.nil?
bot = DiscourseAi::Agents::Bot.as(bot_user, agent: agent)
playground = new(bot)
playground.reply_to(
post,
whisper: whisper,
context_style: :topic,
add_user_to_pm: add_user_to_pm,
stream_reply: stream_reply,
auto_set_title: auto_set_title,
silent_mode: silent_mode,
feature_name: feature_name,
attributed_user: attributed_user,
feature_context: feature_context,
)
rescue => e
raise e
end
def initialize(bot)
@bot = bot
end
def update_playground_with(post)
schedule_bot_reply(post) if can_attach?(post)
end
def title_playground(post, user)
messages =
DiscourseAi::Completions::PromptMessagesBuilder.messages_from_post(
post,
max_posts: 5,
bot_usernames: available_bot_usernames,
include_image_uploads: include_image_uploads?,
include_document_uploads: include_document_uploads?,
allowed_attachment_types: bot.model.allowed_attachment_types,
)
# conversation context may contain tool calls, and confusing user names
# clean it up
conversation = +""
messages.each do |context|
if context[:type] == :user
conversation << "User said:\n#{context[:content]}\n\n"
elsif context[:type] == :model
conversation << "Model said:\n#{context[:content]}\n\n"
end
end
system_insts = <<~TEXT.strip
You are titlebot. Given a conversation, you will suggest a title.
- You will never respond with anything but the suggested title.
- You will always match the conversation language in your title suggestion.
- Title will capture the essence of the conversation.
TEXT
instruction = <<~TEXT.strip
Given the following conversation:
{{{
#{conversation}
}}}
Reply only with a title that is 7 words or less.
TEXT
title_prompt =
DiscourseAi::Completions::Prompt.new(
system_insts,
messages: [type: :user, content: instruction],
topic_id: post.topic_id,
)
new_title =
bot
.llm
.generate(title_prompt, user: user, feature_name: "bot_title")
.strip
.split("\n")
.last
PostRevisor.new(post.topic.first_post, post.topic).revise!(
bot.bot_user,
title: new_title.sub(/\A"/, "").sub(/"\Z/, ""),
)
allowed_users = post.topic.topic_allowed_users.pluck(:user_id)
MessageBus.publish(
"/discourse-ai/ai-bot/topic/#{post.topic.id}",
{ title: post.topic.title },
user_ids: allowed_users,
)
MessageBus.publish(
"/discourse-ai/ai-bot/topic-titles",
{ title: post.topic.title, topic_id: post.topic.id },
user_ids: allowed_users,
)
end
def reply_to_chat_message(message, channel, context_post_ids)
agent_user = User.find(bot.agent.class.user_id)
participants = channel.user_chat_channel_memberships.map { |m| m.user.username }
context_post_ids = nil if !channel.direct_message_channel?
max_chat_messages = 40
if bot.agent.class.respond_to?(:max_context_posts)
max_chat_messages = bot.agent.class.max_context_posts || 40
end
if !channel.direct_message_channel?
# we are interacting via mentions ... strip mention
instruction_message = message.message.gsub(/@#{bot.bot_user.username}/i, "").strip
end
context =
DiscourseAi::Agents::BotContext.new(
participants: participants,
message_id: message.id,
channel_id: channel.id,
context_post_ids: context_post_ids,
messages:
DiscourseAi::Completions::PromptMessagesBuilder.messages_from_chat(
message,
channel: channel,
context_post_ids: context_post_ids,
include_image_uploads: include_image_uploads?,
include_document_uploads: include_document_uploads?,
allowed_attachment_types: bot.model.allowed_attachment_types,
max_messages: max_chat_messages,
bot_user_ids: available_bot_user_ids,
instruction_message: instruction_message,
),
user: message.user,
skip_show_thinking: true,
cancel_manager: DiscourseAi::Completions::CancelManager.new,
)
reply = nil
guardian = Guardian.new(agent_user)
force_thread = message.thread_id.nil? && channel.direct_message_channel?
in_reply_to_id = channel.direct_message_channel? ? message.id : nil
streamer =
ChatStreamer.new(
message: message,
channel: channel,
guardian: guardian,
thread_id: message.thread_id,
in_reply_to_id: in_reply_to_id,
force_thread: force_thread,
cancel_manager: context.cancel_manager,
)
new_prompts =
bot.reply(context) do |partial, placeholder, type|
# no support for thinking by design
next if type == :thinking || type == :partial_tool
streamer << partial
end
reply = streamer.reply
if new_prompts.length > 1 && reply
ChatMessageCustomPrompt.create!(message_id: reply.id, custom_prompt: new_prompts)
end
if streamer
streamer.done
streamer = nil
end
reply
rescue LlmCreditAllocation::CreditLimitExceeded => e
if streamer && streamer.instance_variable_get(:@client_id)
ChatSDK::Channel.stop_reply(
channel_id: channel.id,
client_id: streamer.instance_variable_get(:@client_id),
guardian: guardian,
thread_id: message.thread_id,
)
end
reset_time = e.allocation&.formatted_reset_time || ""
locale_key = message.user.admin? ? "limit_exceeded_admin" : "limit_exceeded_user"
error_message =
I18n.t("discourse_ai.llm_credit_allocation.#{locale_key}", reset_time: reset_time)
# Convert HTML links to markdown format for chat
error_message =
error_message.gsub(%r{<a\s+href=['"]([^'"]+)['"][^>]*>([^<]+)</a>}i, '[\2](\1)')
ChatSDK::Message.create(
raw: error_message,
channel_id: channel.id,
guardian: guardian,
thread_id: message.thread_id,
in_reply_to_id: in_reply_to_id,
force_thread: force_thread,
enforce_membership: !channel.direct_message_channel?,
)
nil
ensure
streamer.done if streamer
end
def reply_to(
post,
custom_instructions: nil,
whisper: nil,
context_style: nil,
add_user_to_pm: true,
stream_reply: nil,
auto_set_title: true,
silent_mode: false,
feature_name: nil,
existing_reply_post: nil,
cancel_manager: nil,
attributed_user: nil,
feature_context: nil,
&blk
)
# this is a multithreading issue
# post custom prompt is needed and it may not
# be properly loaded, ensure it is loaded
PostCustomPrompt.none
if silent_mode
auto_set_title = false
stream_reply = false
end
reply = +""
post_streamer = nil
post_type =
(
if whisper || post.post_type == Post.types[:whisper]
Post.types[:whisper]
else
Post.types[:regular]
end
)
# safeguard
max_context_posts = 40
if bot.agent.class.respond_to?(:max_context_posts)
max_context_posts = bot.agent.class.max_context_posts || 40
end
context =
DiscourseAi::Agents::BotContext.new(
post: post,
user: attributed_user,
custom_instructions: custom_instructions,
feature_name: feature_name,
feature_context: feature_context,
messages:
DiscourseAi::Completions::PromptMessagesBuilder.messages_from_post(
post,
style: context_style,
max_posts: max_context_posts,
include_image_uploads: include_image_uploads?,
include_document_uploads: include_document_uploads?,
allowed_attachment_types: bot.model.allowed_attachment_types,
bot_usernames: available_bot_usernames,
),
)
reply_user = bot.bot_user
if bot.agent.class.respond_to?(:user_id)
reply_user = User.find_by(id: bot.agent.class.user_id) || reply_user
end
stream_reply = post.topic.private_message? if stream_reply.nil?
# we need to ensure agent user is allowed to reply to the pm
if post.topic.private_message? && add_user_to_pm
if !post.topic.topic_allowed_users.exists?(user_id: reply_user.id)
post.topic.topic_allowed_users.create!(user_id: reply_user.id)
end
# edge case, maybe the llm user is missing?
if !post.topic.topic_allowed_users.exists?(user_id: bot.bot_user.id)
post.topic.topic_allowed_users.create!(user_id: bot.bot_user.id)
end
# we store the id of the last bot_user, this is then used to give it preference
if post.topic.custom_fields[BOT_USER_PREF_ID_CUSTOM_FIELD].to_i != bot.bot_user.id
post.topic.custom_fields[BOT_USER_PREF_ID_CUSTOM_FIELD] = bot.bot_user.id
post.topic.save_custom_fields
end
end
if stream_reply
reply_post = existing_reply_post
if reply_post
if reply_post.topic_id != post.topic_id
raise Discourse::InvalidParameters.new(:reply_post_id)
end
if reply_post.user_id != reply_user.id
raise Discourse::InvalidParameters.new(:reply_post_id)
end
reply_post.update_columns(raw: "", cooked: "")
reply_post.post_custom_prompt = nil
else
reply_post =
PostCreator.create!(
reply_user,
topic_id: post.topic_id,
raw: "",
skip_validations: true,
skip_jobs: true,
post_type: post_type,
skip_guardian: true,
custom_fields: {
DiscourseAi::AiBot::POST_AI_LLM_NAME_FIELD => bot.llm.llm_model.display_name,
DiscourseAi::AiBot::POST_AI_LLM_MODEL_ID_FIELD => bot.llm.llm_model.id,
DiscourseAi::AiBot::POST_AI_AGENT_ID_FIELD => bot.agent.id,
},
)
end
reply_post.custom_fields[DiscourseAi::AiBot::POST_AI_LLM_NAME_FIELD] = bot
.llm
.llm_model
.display_name
reply_post.custom_fields[DiscourseAi::AiBot::POST_AI_LLM_MODEL_ID_FIELD] = bot
.llm
.llm_model
.id
reply_post.custom_fields[DiscourseAi::AiBot::POST_AI_AGENT_ID_FIELD] = bot.agent.id
reply_post.save_custom_fields
publish_update(reply_post, { raw: "" })
redis_stream_key = "gpt_cancel:#{reply_post.id}"
Discourse.redis.setex(redis_stream_key, MAX_STREAM_DELAY_SECONDS, 1)
cancel_manager ||= DiscourseAi::Completions::CancelManager.new
context.cancel_manager = cancel_manager
context
.cancel_manager
.start_monitor(delay: 0.2) do
context.cancel_manager.cancel! if !Discourse.redis.get(redis_stream_key)
end
context.cancel_manager.add_callback(
lambda { reply_post.update!(raw: reply, cooked: PrettyText.cook(reply)) },
)
end
context.skip_show_thinking ||= !bot.agent.class.show_thinking
post_streamer = PostStreamer.new(delay: Rails.env.test? ? 0 : 0.5) if stream_reply
started_thinking = false
new_custom_prompts =
bot.reply(context) do |partial, placeholder, type|
if context.skip_show_thinking && %i[thinking partial_tool partial_invoke].include?(type)
next
end
next if type == :structured_output && !partial.finished?
if should_start_thinking?(partial:, context:, type:, started_thinking:, placeholder:)
reply << "\n\n" if reply.present? && !reply.end_with?("\n")
reply << "<details class='ai-thinking'><summary>#{I18n.t("discourse_ai.ai_bot.thinking")}</summary>\n\n"
started_thinking = true
elsif should_stop_thinking?(partial:, context:, type:, started_thinking:, placeholder:)
reply << "</details>\n\n"
started_thinking = false
end
if type == :thinking && partial.present? && placeholder.blank? && started_thinking &&
!reply.end_with?("\n")
reply << "\n\n"
end
reply << partial
raw = reply.dup
raw << "\n\n" << placeholder if placeholder.present?
if blk && type != :thinking && type != :partial_tool && type != :partial_invoke
blk.call(partial)
end
if post_streamer
post_streamer.run_later do
Discourse.redis.expire(redis_stream_key, MAX_STREAM_DELAY_SECONDS)
publish_update(reply_post, { raw: raw })
end
end
end
return if reply.blank? || silent_mode
if started_thinking
reply << "\n\n</details>"
started_thinking = false
end
if stream_reply
post_streamer.finish
post_streamer = nil
# land the final message prior to saving so we don't clash
reply_post.cooked = PrettyText.cook(reply)
publish_final_update(reply_post)
reply_post.revise(
bot.bot_user,
{ raw: reply },
skip_validations: true,
skip_revision: true,
)
else
reply_post =
PostCreator.create!(
reply_user,
topic_id: post.topic_id,
raw: reply,
skip_validations: true,
post_type: post_type,
skip_guardian: true,
custom_fields: {
DiscourseAi::AiBot::POST_AI_LLM_NAME_FIELD => bot.llm.llm_model.display_name,
DiscourseAi::AiBot::POST_AI_LLM_MODEL_ID_FIELD => bot.llm.llm_model.id,
DiscourseAi::AiBot::POST_AI_AGENT_ID_FIELD => bot.agent.id,
},
)
end
# a bit messy internally, but this is how we tell
is_thinking = new_custom_prompts.any? { |prompt| prompt[4].present? }
if is_thinking || new_custom_prompts.length > 1
reply_post.post_custom_prompt ||= reply_post.build_post_custom_prompt(custom_prompt: [])
prompt = reply_post.post_custom_prompt.custom_prompt || []
prompt.concat(new_custom_prompts)
reply_post.post_custom_prompt.update!(custom_prompt: prompt)
end
reply_post
rescue LlmCreditAllocation::CreditLimitExceeded => e
return if silent_mode
reset_time = e.allocation&.formatted_reset_time || ""
locale_key = post.user.admin? ? "limit_exceeded_admin" : "limit_exceeded_user"
error_message =
I18n.t("discourse_ai.llm_credit_allocation.#{locale_key}", reset_time: reset_time)
if reply_post
reply = "#{reply}#{started_thinking ? "\n\n</details>" : ""}\n\n#{error_message}"
reply_post.revise(
bot.bot_user,
{ raw: reply },
skip_validations: true,
skip_revision: true,
)
else
PostCreator.create!(
bot.bot_user,
topic_id: post.topic_id,
raw: error_message,
skip_validations: true,
skip_guardian: true,
)
end
nil
rescue => e
if reply_post
details = e.message.to_s
reply =
"#{reply}#{started_thinking ? "\n\n</details>" : ""}\n\n#{I18n.t("discourse_ai.ai_bot.reply_error", details: details)}"
reply_post.revise(
bot.bot_user,
{ raw: reply },
skip_validations: true,
skip_revision: true,
)
end
raise e
ensure
context.cancel_manager.stop_monitor if context&.cancel_manager
# since we are skipping validations and jobs we
# may need to fix participant count
if reply_post && reply_post.topic && reply_post.topic.private_message? &&
reply_post.topic.participant_count < 2
reply_post.topic.update!(participant_count: 2)
end
post_streamer&.finish(skip_callback: true)
publish_final_update(reply_post) if stream_reply
if reply_post && post.post_number == 1 && post.topic.private_message? && auto_set_title
title_playground(reply_post, post.user)
end
end
def available_bot_usernames
@bot_usernames ||=
AiAgent.joins(:user).pluck(:username).concat(available_bot_users.map(&:username))
end
def available_bot_user_ids
@bot_ids ||= AiAgent.joins(:user).pluck("users.id").concat(available_bot_users.map(&:id))
end
def include_image_uploads?
bot.agent.class.vision_enabled
end
def include_document_uploads?
bot.model.allowed_attachment_types.present?
end
private
def should_stop_thinking?(partial:, context:, type:, started_thinking:, placeholder:)
return false if context.skip_show_thinking
return false if !started_thinking
return false if partial.blank? && placeholder.blank?
return true if type.nil? || type == :structured_output || type == :custom_raw
false
end
def should_start_thinking?(partial:, context:, type:, started_thinking:, placeholder:)
return false if context.skip_show_thinking
return false if started_thinking
return false if partial.blank? && placeholder.blank?
return false if type.nil? || type == :structured_output || type == :custom_raw
true
end
def available_bot_users
@available_bots ||=
User.joins("INNER JOIN llm_models llm ON llm.user_id = users.id").where(active: true)
end
def publish_final_update(reply_post)
return if @published_final_update
if reply_post
publish_update(reply_post, { cooked: reply_post.cooked, done: true })
# we subscribe at position -2 so we will always get this message
# moving all cooked on every page load is wasteful ... this means
# we have a benign message at the end, 2 is set to ensure last message
# is delivered
publish_update(reply_post, { noop: true })
@published_final_update = true
end
end
def can_attach?(post)
return false if bot.bot_user.nil?
return false if post.topic.private_message? && post.post_type != Post.types[:regular]
return false if (SiteSetting.ai_bot_allowed_groups_map & post.user.group_ids).blank?
return false if post.custom_fields[BYPASS_AI_REPLY_CUSTOM_FIELD].present?
true
end
def schedule_bot_reply(post)
agent_id = DiscourseAi::Agents::Agent.system_agents[bot.agent.class] || bot.agent.class.id
::Jobs.enqueue(
:create_ai_reply,
post_id: post.id,
bot_user_id: bot.bot_user.id,
agent_id: agent_id,
)
end
def context(topic)
{
site_url: Discourse.base_url,
site_title: SiteSetting.title,
site_description: SiteSetting.site_description,
time: Time.zone.now,
participants: topic.allowed_users.map(&:username).join(", "),
}
end
def publish_update(bot_reply_post, payload)
payload = { post_id: bot_reply_post.id, post_number: bot_reply_post.post_number }.merge(
payload,
)
MessageBus.publish(
"discourse-ai/ai-bot/topic/#{bot_reply_post.topic_id}",
payload,
user_ids: bot_reply_post.topic.allowed_user_ids,
max_backlog_size: 2,
max_backlog_age: MAX_STREAM_DELAY_SECONDS,
)
end
end
end
end