discourse/plugins/discourse-ai/lib/configuration/llm_enumerator.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

136 lines
5 KiB
Ruby
Vendored

# frozen_string_literal: true
require "enum_site_setting"
module DiscourseAi
module Configuration
class LlmEnumerator < ::EnumSiteSetting
def self.global_usage
rval = Hash.new { |h, k| h[k] = [] }
if SiteSetting.ai_bot_enabled
LlmModel.enabled_chat_bot_ids.each { |llm_id| rval[llm_id] << { type: :ai_bot } }
end
# this is unconditional, so it is clear that we always signal configuration
AiAgent
.where.not(default_llm_id: nil)
.pluck(:default_llm_id, :name, :id)
.each { |llm_id, name, id| rval[llm_id] << { type: :ai_agent, name: name, id: id } }
if SiteSetting.ai_helper_enabled
{
"#{I18n.t("js.discourse_ai.features.ai_helper.proofread")}" =>
SiteSetting.ai_helper_proofreader_agent,
"#{I18n.t("js.discourse_ai.features.ai_helper.title_suggestions")}" =>
SiteSetting.ai_helper_title_suggestions_agent,
"#{I18n.t("js.discourse_ai.features.ai_helper.explain")}" =>
SiteSetting.ai_helper_explain_agent,
"#{I18n.t("js.discourse_ai.features.ai_helper.illustrate_post")}" =>
SiteSetting.ai_helper_post_illustrator_agent,
"#{I18n.t("js.discourse_ai.features.ai_helper.smart_dates")}" =>
SiteSetting.ai_helper_smart_dates_agent,
"#{I18n.t("js.discourse_ai.features.ai_helper.translator")}" =>
SiteSetting.ai_helper_translator_agent,
"#{I18n.t("js.discourse_ai.features.ai_helper.markdown_tables")}" =>
SiteSetting.ai_helper_markdown_tables_agent,
"#{I18n.t("js.discourse_ai.features.ai_helper.custom_prompt")}" =>
SiteSetting.ai_helper_custom_prompt_agent,
}.each do |helper_type, agent_id|
next if agent_id.blank?
agent = AiAgent.find_by(id: agent_id)
next if agent.blank? || agent.default_llm_id.blank?
model_id = agent.default_llm_id || SiteSetting.ai_default_llm_model.to_i
rval[model_id] << { type: :ai_helper, name: helper_type }
end
end
if SiteSetting.ai_helper_enabled_features.split("|").include?("image_caption")
image_caption_agent = AiAgent.find_by(id: SiteSetting.ai_helper_image_caption_agent)
model_id = image_caption_agent.default_llm_id || SiteSetting.ai_default_llm_model.to_i
rval[model_id] << { type: :ai_helper_image_caption }
end
if SiteSetting.ai_summarization_enabled
summarization_agent = AiAgent.find_by(id: SiteSetting.ai_summarization_agent)
model_id = summarization_agent.default_llm_id || SiteSetting.ai_default_llm_model.to_i
rval[model_id] << { type: :ai_summarization }
end
if SiteSetting.ai_embeddings_semantic_search_enabled
search_agent = AiAgent.find_by(id: SiteSetting.ai_embeddings_semantic_search_hyde_agent)
model_id = search_agent.default_llm_id || SiteSetting.ai_default_llm_model.to_i
rval[model_id] << { type: :ai_embeddings_semantic_search }
end
if SiteSetting.ai_spam_detection_enabled && AiModerationSetting.spam.present?
model_id = AiModerationSetting.spam[:llm_model_id]
rval[model_id] << { type: :ai_spam }
end
if defined?(DiscourseAutomation::Automation)
DiscourseAutomation::Automation
.joins(:fields)
.where(script: %w[llm_report llm_triage])
.where("discourse_automation_fields.name = ?", "model")
.pluck(
"metadata ->> 'value', discourse_automation_automations.name, discourse_automation_automations.id",
)
.each do |model_text, name, id|
next if model_text.blank?
model_id = model_text.to_i
rval[model_id] << { type: :automation, name: name, id: id } if model_id.present?
end
end
rval
end
def self.valid_value?(val)
true
end
# returns an array of hashes (id: , name:, vision_enabled:, supported_native_tools:)
def self.values_for_serialization
return [] unless table_exists?
llm_models = LlmModel.all.index_by(&:id)
DB
.query_hash(<<~SQL)
SELECT id, display_name AS name, vision_enabled
FROM llm_models
SQL
.map do |row|
row = row.symbolize_keys
llm_model = llm_models[row[:id]]
row[:supported_native_tools] = DiscourseAi::Completions::NativeTools.supported_ids_for(
llm_model,
)
row
end
end
def self.values
return [] unless table_exists?
DB.query_hash(<<~SQL).map(&:symbolize_keys)
SELECT display_name AS name, id AS value
FROM llm_models
SQL
end
def self.table_exists?
DB.exec("SELECT 1 FROM llm_models LIMIT 0")
true
rescue StandardError
false
end
end
end
end