mirror of
https://github.com/discourse/discourse.git
synced 2026-08-08 17:53:55 +08:00
Previously, approval-required Admin Assistant tools chained into another model response, which could replace the interactive approval card with a non-interactive pending-approval summary. This change adds a default-off `stop_chain_on_pending_approval?` agent capability, enables it for the Admin Assistant, processes every tool call in the current response, and then leaves the approval cards as the authoritative result.
479 lines
13 KiB
Ruby
Vendored
479 lines
13 KiB
Ruby
Vendored
#frozen_string_literal: true
|
|
|
|
module DiscourseAi
|
|
module Agents
|
|
class Agent
|
|
class << self
|
|
def default_enabled
|
|
true
|
|
end
|
|
|
|
def rag_conversation_chunks
|
|
10
|
|
end
|
|
|
|
def vision_enabled
|
|
false
|
|
end
|
|
|
|
def vision_max_pixels
|
|
1_048_576
|
|
end
|
|
|
|
def max_turn_tokens
|
|
nil
|
|
end
|
|
|
|
def compression_threshold
|
|
nil
|
|
end
|
|
|
|
def force_default_llm
|
|
false
|
|
end
|
|
|
|
def allow_chat_channel_mentions
|
|
false
|
|
end
|
|
|
|
def allow_chat_direct_messages
|
|
false
|
|
end
|
|
|
|
def system_agents
|
|
sync_external_registry!
|
|
@system_agents ||= builtin_system_agents
|
|
end
|
|
|
|
def system_agents_by_id
|
|
@system_agents_by_id ||= system_agents.invert
|
|
end
|
|
|
|
def external_tool_by_name(name)
|
|
sync_external_registry!
|
|
@external_tools_by_name[name]
|
|
end
|
|
|
|
def external_tools
|
|
sync_external_registry!
|
|
@external_tools_by_name.values
|
|
end
|
|
|
|
def all(user:)
|
|
# listing tools has to be dynamic cause site settings may change
|
|
AiAgent.all_agents.filter do |agent|
|
|
next false if !user.in_any_groups?(agent.allowed_group_ids)
|
|
|
|
if agent.system
|
|
instance = agent.new
|
|
instance.required_tools == [] ||
|
|
(instance.required_tools - all_available_tools).empty?
|
|
else
|
|
true
|
|
end
|
|
end
|
|
end
|
|
|
|
def find_by(id: nil, name: nil, user:)
|
|
all(user: user).find { |agent| agent.id == id || agent.name == name }
|
|
end
|
|
|
|
def name
|
|
I18n.t("discourse_ai.ai_bot.agents.#{to_s.demodulize.underscore}.name")
|
|
end
|
|
|
|
def description
|
|
I18n.t("discourse_ai.ai_bot.agents.#{to_s.demodulize.underscore}.description")
|
|
end
|
|
|
|
def all_available_tools
|
|
tools = [
|
|
Tools::ListCategories,
|
|
Tools::Time,
|
|
Tools::Search,
|
|
Tools::Read,
|
|
Tools::FlagPost,
|
|
Tools::CloseTopic,
|
|
Tools::SuspendUser,
|
|
Tools::SilenceUser,
|
|
Tools::UnlistTopic,
|
|
Tools::LockPost,
|
|
Tools::DeleteTopic,
|
|
Tools::EditPost,
|
|
Tools::EditCategory,
|
|
Tools::SetTopicTimer,
|
|
Tools::SetSlowMode,
|
|
Tools::MovePosts,
|
|
Tools::GrantBadge,
|
|
Tools::ListReviewables,
|
|
Tools::PerformReviewableAction,
|
|
Tools::AddReviewableNote,
|
|
Tools::DbSchema,
|
|
Tools::SearchSettings,
|
|
Tools::SettingContext,
|
|
Tools::ReadSiteSetting,
|
|
Tools::ChangeSiteSetting,
|
|
Tools::RandomPicker,
|
|
Tools::DiscourseMetaSearch,
|
|
Tools::GithubFileContent,
|
|
Tools::GithubDiff,
|
|
Tools::GithubSearchFiles,
|
|
Tools::WebBrowser,
|
|
Tools::JavascriptEvaluator,
|
|
Tools::Researcher,
|
|
]
|
|
|
|
if SiteSetting.ai_artifact_security.in?(%w[lax hybrid strict])
|
|
tools << Tools::CreateArtifact
|
|
tools << Tools::UpdateArtifact
|
|
tools << Tools::ReadArtifact
|
|
end
|
|
|
|
tools << Tools::GithubSearchCode if SiteSetting.ai_bot_github_access_token.present?
|
|
|
|
if SiteSetting.tagging_enabled
|
|
tools << Tools::ListTags
|
|
tools << Tools::EditTags
|
|
end
|
|
|
|
# Image generation tools - use custom UI-configured tools
|
|
if Tools::Tool.available_custom_image_tools.present?
|
|
tools << Tools::Image
|
|
tools << Tools::CreateImage
|
|
tools << Tools::EditImage
|
|
end
|
|
|
|
if SiteSetting.ai_google_custom_search_api_key.present? &&
|
|
SiteSetting.ai_google_custom_search_cx.present?
|
|
tools << Tools::Google
|
|
end
|
|
|
|
tools << Tools::Assign if defined?(::Assigner)
|
|
tools << Tools::MarkAsSolved if defined?(::DiscourseSolved)
|
|
|
|
tools
|
|
end
|
|
|
|
def external_agent_id(agent_klass)
|
|
-(Digest::SHA1.hexdigest(agent_klass.to_s).to_i(16) % 1_000_000 + 1_000_000)
|
|
end
|
|
|
|
private
|
|
|
|
def sync_external_registry!
|
|
configs = external_feature_configs
|
|
signature = configs.hash
|
|
if @external_registry_signature == signature && @external_tools_by_name && @system_agents
|
|
return
|
|
end
|
|
|
|
@external_registry_signature = signature
|
|
external_agents = {}
|
|
external_tools_by_name = {}
|
|
|
|
configs.each do |config|
|
|
agent_klass = config[:agent_klass]
|
|
next if agent_klass.nil?
|
|
next if external_agents.key?(agent_klass)
|
|
next if builtin_system_agents.key?(agent_klass)
|
|
|
|
external_agents[agent_klass] = config[:agent_id]
|
|
|
|
agent_klass.new.tools.each do |tool_klass|
|
|
tool_name = tool_klass.to_s.split("::").last
|
|
next if "DiscourseAi::Agents::Tools::#{tool_name}".safe_constantize
|
|
external_tools_by_name[tool_name] ||= tool_klass
|
|
end
|
|
end
|
|
|
|
new_system_agents = builtin_system_agents.merge(external_agents)
|
|
@system_agents_by_id = nil if @system_agents != new_system_agents
|
|
@system_agents = new_system_agents
|
|
@external_tools_by_name = external_tools_by_name
|
|
end
|
|
|
|
def external_feature_configs
|
|
return [] if !DiscoursePluginRegistry.respond_to?(:_raw_external_ai_features)
|
|
|
|
DiscoursePluginRegistry
|
|
._raw_external_ai_features
|
|
.pluck(:value)
|
|
.each do |config|
|
|
config[:agent_id] ||= external_agent_id(config[:agent_klass]) if config[:agent_klass]
|
|
end
|
|
end
|
|
|
|
def builtin_system_agents
|
|
@builtin_system_agents ||= {
|
|
General => -1,
|
|
SqlHelper => -2,
|
|
Artist => -3,
|
|
SettingsExplorer => -4,
|
|
Researcher => -5,
|
|
Creative => -6,
|
|
DiscourseHelper => -8,
|
|
GithubHelper => -9,
|
|
WebArtifactCreator => -10,
|
|
Summarizer => -11,
|
|
ShortSummarizer => -12,
|
|
Designer => -13,
|
|
ForumResearcher => -14,
|
|
ConceptFinder => -15,
|
|
ConceptMatcher => -16,
|
|
ConceptDeduplicator => -17,
|
|
CustomPrompt => -18,
|
|
SmartDates => -19,
|
|
MarkdownTableGenerator => -20,
|
|
PostIllustrator => -21,
|
|
Proofreader => -22,
|
|
TitlesGenerator => -23,
|
|
Tutor => -24,
|
|
Translator => -25,
|
|
ImageCaptioner => -26,
|
|
LocaleDetector => -27,
|
|
PostRawTranslator => -28,
|
|
TopicTitleTranslator => -29,
|
|
ShortTextTranslator => -30,
|
|
SpamDetector => -31,
|
|
ContentCreator => -32,
|
|
ReportRunner => -33,
|
|
Discover => -34,
|
|
ChatThreadTitler => -35,
|
|
SentimentClassifier => -36,
|
|
EmotionClassifier => -37,
|
|
AdminDashboardHighlights => -38,
|
|
DiscourseAdminAssistant => -39,
|
|
}.freeze
|
|
end
|
|
end
|
|
|
|
def id
|
|
@ai_agent&.id || self.class.system_agents[self.class.superclass] ||
|
|
self.class.system_agents[self.class]
|
|
end
|
|
|
|
def tools
|
|
[]
|
|
end
|
|
|
|
def force_tool_use
|
|
[]
|
|
end
|
|
|
|
def forced_tool_count
|
|
-1
|
|
end
|
|
|
|
def required_tools
|
|
[]
|
|
end
|
|
|
|
def stop_chain_on_pending_approval?
|
|
false
|
|
end
|
|
|
|
def temperature
|
|
nil
|
|
end
|
|
|
|
def top_p
|
|
nil
|
|
end
|
|
|
|
def thinking_effort
|
|
nil
|
|
end
|
|
|
|
def options
|
|
{}
|
|
end
|
|
|
|
def response_format
|
|
nil
|
|
end
|
|
|
|
def examples
|
|
[]
|
|
end
|
|
|
|
def native_tools
|
|
[]
|
|
end
|
|
|
|
def available_tools
|
|
self
|
|
.class
|
|
.all_available_tools
|
|
.filter { |tool| tools.include?(tool) }
|
|
.concat(tools.filter(&:custom?))
|
|
.tap do |available_tools|
|
|
next if !rag_tool_available?
|
|
if available_tools.any? { |tool|
|
|
tool.signature[:name] == Tools::SearchUploadedDocuments.name
|
|
}
|
|
next
|
|
end
|
|
|
|
available_tools << Tools::SearchUploadedDocuments
|
|
end
|
|
.uniq
|
|
end
|
|
|
|
def craft_prompt(context, llm: nil)
|
|
available_tools = self.available_tools
|
|
system_insts = replace_placeholders(system_prompt, context)
|
|
|
|
prompt_insts = <<~TEXT.strip
|
|
#{system_insts}
|
|
#{available_tools.map(&:custom_system_message).compact_blank.join("\n")}
|
|
TEXT
|
|
|
|
if context.custom_instructions.present?
|
|
prompt_insts << "\n"
|
|
prompt_insts << context.custom_instructions
|
|
end
|
|
|
|
post_system_examples = []
|
|
|
|
if examples.present?
|
|
examples.flatten.each_with_index do |e, idx|
|
|
post_system_examples << {
|
|
content: replace_placeholders(e, context),
|
|
type: (idx + 1).odd? ? :user : :model,
|
|
}
|
|
end
|
|
end
|
|
|
|
prompt =
|
|
DiscourseAi::Completions::Prompt.new(
|
|
prompt_insts,
|
|
messages: post_system_examples.concat(context.messages),
|
|
topic_id: context.topic_id,
|
|
post_id: context.post_id,
|
|
)
|
|
|
|
prompt.max_pixels = self.class.vision_max_pixels if self.class.vision_enabled
|
|
prompt.tools = available_tools.map(&:signature) if available_tools
|
|
prompt.native_tools = native_tools if native_tools.present?
|
|
available_tools.each do |tool|
|
|
tool.inject_prompt(prompt: prompt, context: context, agent: self)
|
|
end
|
|
prompt
|
|
end
|
|
|
|
def find_tool(partial, bot_user:, llm:, context:, existing_tools: [])
|
|
return nil if !partial.is_a?(DiscourseAi::Completions::ToolCall)
|
|
tool_instance(
|
|
partial,
|
|
bot_user: bot_user,
|
|
llm: llm,
|
|
context: context,
|
|
existing_tools: existing_tools,
|
|
)
|
|
end
|
|
|
|
def allow_partial_tool_calls?
|
|
available_tools.any? { |tool| tool.allow_partial_tool_calls? }
|
|
end
|
|
|
|
protected
|
|
|
|
def replace_placeholders(content, context)
|
|
replaced =
|
|
content.gsub(/\{(\w+)\}/) do |match|
|
|
found = context.lookup_template_param(match[1..-2])
|
|
found.nil? ? match : found.to_s
|
|
end
|
|
|
|
return replaced if !context.format_dates
|
|
|
|
DiscourseAi::AiHelper::DateFormatter.process_date_placeholders(replaced, context.user)
|
|
end
|
|
|
|
def tool_instance(tool_call, bot_user:, llm:, context:, existing_tools:)
|
|
function_id = tool_call.id
|
|
function_name = tool_call.name
|
|
return nil if function_name.nil?
|
|
|
|
tool_klass = available_tools.find { |c| c.signature.dig(:name) == function_name }
|
|
return nil if tool_klass.nil?
|
|
|
|
arguments =
|
|
if tool_klass.signature[:json_schema]
|
|
tool_call.parameters
|
|
else
|
|
coerce_tool_arguments(tool_klass.signature[:parameters].to_a, tool_call)
|
|
end
|
|
|
|
tool_instance =
|
|
existing_tools.find { |t| t.name == function_name && t.tool_call_id == function_id }
|
|
|
|
if tool_instance
|
|
tool_instance.parameters = arguments
|
|
tool_instance.provider_data = tool_call.provider_data if tool_instance.respond_to?(
|
|
:provider_data=,
|
|
)
|
|
tool_instance
|
|
else
|
|
tool_klass.new(
|
|
arguments,
|
|
tool_call_id: function_id || function_name,
|
|
agent_options: options[tool_klass].to_h,
|
|
bot_user: bot_user,
|
|
llm: llm,
|
|
context: context,
|
|
provider_data: tool_call.provider_data,
|
|
agent: self,
|
|
)
|
|
end
|
|
end
|
|
|
|
def rag_tool_available?
|
|
return false if !DiscourseAi::Embeddings.enabled?
|
|
return false if id.blank?
|
|
|
|
UploadReference.where(target_id: id, target_type: "AiAgent").exists?
|
|
end
|
|
|
|
def coerce_tool_arguments(param_defs, tool_call)
|
|
arguments = {}
|
|
param_defs.each do |param|
|
|
name = param[:name]
|
|
value = tool_call.parameters[name.to_sym]
|
|
|
|
if param[:type] == "array" && value
|
|
value =
|
|
begin
|
|
JSON.parse(value)
|
|
rescue JSON::ParserError
|
|
[value.to_s]
|
|
end
|
|
elsif param[:type] == "string" && value
|
|
value = strip_quotes(value).to_s
|
|
elsif param[:type] == "integer" && value
|
|
value = strip_quotes(value).to_i
|
|
end
|
|
|
|
value = nil if param[:enum] && value && !param[:enum].include?(value)
|
|
|
|
arguments[name.to_sym] = value if value
|
|
end
|
|
arguments
|
|
end
|
|
|
|
def strip_quotes(value)
|
|
if value.is_a?(String)
|
|
if value.start_with?('"') && value.end_with?('"')
|
|
value = value[1..-2]
|
|
elsif value.start_with?("'") && value.end_with?("'")
|
|
value = value[1..-2]
|
|
else
|
|
value
|
|
end
|
|
else
|
|
value
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|