discourse/plugins/discourse-ai/lib/completions/native_tools.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

86 lines
2.4 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscourseAi
module Completions
# Registry of provider-native built-in tools.
#
# Unlike shipped/custom/MCP tools, these are executed server-side by the LLM
# provider (e.g. Gemini Google Search grounding, OpenAI web search, Anthropic
# web search). Discourse only declares them in the request payload; there is
# no tool-call round-trip back to us. They are stored on an agent's `tools`
# column with a "native-" prefix and rendered into the request by each
# provider dialect.
module NativeTools
PREFIX = "native-"
WEB_SEARCH = "web_search"
WEB_FETCH = "web_fetch"
# open_ai/azure only expose web search through the Responses API
RESPONSES_API_PROVIDERS = %w[open_ai azure].freeze
class Definition
attr_reader :id, :providers
def initialize(id:, providers:)
@id = id
@providers = providers
end
def name
I18n.t("discourse_ai.ai_bot.native_tools.#{id}.name")
end
def help
I18n.t("discourse_ai.ai_bot.native_tools.#{id}.help")
end
def supported?(llm_model)
return false if llm_model.blank?
provider = llm_model.provider
return false if providers.exclude?(provider)
if RESPONSES_API_PROVIDERS.include?(provider)
return llm_model.url.to_s.include?("/v1/responses")
end
true
end
end
DEFINITIONS = [
Definition.new(id: WEB_SEARCH, providers: %w[google anthropic open_ai azure]),
Definition.new(id: WEB_FETCH, providers: %w[google anthropic]),
].freeze
def self.all
DEFINITIONS
end
def self.find(id)
id = strip_prefix(id)
DEFINITIONS.find { |definition| definition.id == id }
end
def self.valid?(id)
!find(id).nil?
end
# ids supported by a given LlmModel (encapsulates the Responses-API nuance)
def self.supported_ids_for(llm_model)
return [] if llm_model.blank?
DEFINITIONS.select { |definition| definition.supported?(llm_model) }.map(&:id)
end
def self.prefixed?(name)
name.is_a?(String) && name.start_with?(PREFIX)
end
def self.strip_prefix(name)
return name unless prefixed?(name)
name.delete_prefix(PREFIX)
end
end
end
end