0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-07 13:19:19 +08:00
discourse/plugins/discourse-ai/discourse_workflows/nodes/ai_agent/v1.rb
Joffrey JAFFEUX dfe770d040
FEATURE: better summarize primitives for workflows (#42297)
- Introduces the summarize node which allows to group items into a more
structured output. It comes with a new preview feature which allows to
show live what is going to be the output of this node, which we can
later implement for other nodes.
- Defaults to run AI agent once for all items which was otherwise
defaulting to once for each item and was a bad default (could be very
costly).
2026-08-04 10:20:09 +02:00

411 lines
14 KiB
Ruby
Vendored

# frozen_string_literal: true
if defined?(DiscourseWorkflows)
module DiscourseWorkflows
module Nodes
module AiAgent
class V1 < DiscourseWorkflows::NodeType
RUN_ONCE_FOR_ALL_ITEMS = "runOnceForAllItems"
RUN_ONCE_FOR_EACH_ITEM = "runOnceForEachItem"
description(
name: "action:ai_agent",
version: "1.0",
defaults: {
icon: "robot",
color: "pink",
},
group: "ai",
available: -> { SiteSetting.discourse_ai_enabled },
unavailable_reason_key: "discourse_workflows.node_unavailable.requires_ai",
i18n_prefix: "discourse_ai.discourse_workflows",
capabilities: {
run_scope: {
parameter: "mode",
values: {
RUN_ONCE_FOR_EACH_ITEM => "per_item",
RUN_ONCE_FOR_ALL_ITEMS => "all_items",
},
},
},
output_contracts: [
{
schema: {
"$schema" => DiscourseWorkflows::Schema::DRAFT_URI,
"type" => "object",
"properties" => {
"result" => {
"type" => "string",
},
},
},
},
],
properties: {
agent_id: {
type: :integer,
required: true,
type_options: {
load_options_method: "agents",
},
no_data_expression: true,
ui: {
control: :combo_box,
},
control_options: {
action_icon: "robot",
action_label: "discourse_ai.ai_agent.manage_agents",
action_route: "adminPlugins.show.discourse-ai-agents",
action_route_models: ["discourse-ai"],
filterable: true,
value_property: :id,
name_property: :name,
set_from_option: {
agent_name: "name",
agent_force_default_llm: "force_default_llm",
agent_resolved_llm_name: "resolved_llm_name",
},
},
},
agent_name: {
type: :string,
ui: {
hidden: true,
},
},
agent_force_default_llm: {
type: :boolean,
default: false,
ui: {
hidden: true,
},
},
agent_resolved_llm_name: {
type: :string,
ui: {
hidden: true,
},
},
llm_model_id: {
type: :integer,
required: false,
type_options: {
load_options_method: "llm_models",
},
no_data_expression: true,
ui: {
control: :combo_box,
},
control_options: {
filterable: true,
value_property: :id,
name_property: :name,
none: "discourse_ai.discourse_workflows.ai_agent.llm_model_default",
none_label_field: "agent_resolved_llm_name",
none_label_i18n_key:
"discourse_ai.discourse_workflows.ai_agent.llm_model_default_with_name",
},
display_options: {
hide: {
agent_force_default_llm: [true],
},
},
},
forced_llm_notice: {
type: :notice,
display_options: {
show: {
agent_force_default_llm: [true],
},
},
},
runner_username: {
type: :string,
required: false,
default: "system",
ui: {
control: :actor,
},
},
mode: {
type: :options,
required: true,
options: [RUN_ONCE_FOR_EACH_ITEM, RUN_ONCE_FOR_ALL_ITEMS],
default: RUN_ONCE_FOR_EACH_ITEM,
no_data_expression: true,
},
prompt: {
type: :string,
ui: {
control: :textarea,
},
},
upload_ids: {
type: :array,
required: false,
default: [],
ui: {
control: :multi_input,
expression: true,
},
},
},
)
def self.group_definition
{ icon: "robot", label_key: "discourse_workflows.add_node.categories.ai", order: 40 }
end
def self.load_options_context(context)
case context.method_name
when "agents"
agent_options.select { |agent| context.matches_filter?(agent[:name]) }
when "llm_models"
llm_model_options(context)
end
end
def self.agent_options
agents =
::AiAgent
.where(enabled: true)
.order(:name)
.pluck(:id, :name, :default_llm_id, :force_default_llm)
site_default_llm_id = SiteSetting.ai_default_llm_model.presence&.to_i
llm_model_ids = agents.map { |_id, _name, default_llm_id, _force| default_llm_id }
llm_model_ids << site_default_llm_id
llm_models_by_id = ::LlmModel.where(id: llm_model_ids.compact.uniq).index_by(&:id)
default_llm = llm_models_by_id[site_default_llm_id]
agents.map do |id, name, default_llm_id, force_default_llm|
configured_llm = llm_models_by_id[default_llm_id]
resolved_llm = force_default_llm ? configured_llm : configured_llm || default_llm
{
id: id,
name: name,
default_llm_id: default_llm_id,
force_default_llm: force_default_llm,
resolved_llm_id: resolved_llm&.id,
resolved_llm_name: resolved_llm&.display_name,
}
end
end
def self.llm_model_options(context)
::LlmModel
.order(:display_name)
.pluck(:id, :display_name)
.filter_map do |id, display_name|
next if display_name.blank?
{ id: id, name: display_name }
end
.select { |llm_model| context.matches_filter?(llm_model[:name]) }
end
def execute(exec_ctx)
mode = exec_ctx.get_node_parameter("mode", 0, default: RUN_ONCE_FOR_EACH_ITEM)
validate_mode!(mode)
return [[run_once_for_all_items(exec_ctx)]] if mode == RUN_ONCE_FOR_ALL_ITEMS
items =
exec_ctx.input_items.map.with_index do |item, item_index|
result =
run_agent(
agent_config(exec_ctx, item_index),
exec_ctx.log,
runner(exec_ctx, item_index),
)
wrap({ "result" => result }, paired_item: exec_ctx.paired_item_for(item))
end
[items]
end
private
def run_once_for_all_items(exec_ctx)
result = run_agent(agent_config(exec_ctx, 0), exec_ctx.log, runner(exec_ctx, 0))
wrap(
{ "result" => result },
paired_item: exec_ctx.input_items.map { |item| exec_ctx.paired_item_for(item) },
)
end
def agent_config(exec_ctx, item_index)
{
"agent_id" => exec_ctx.get_node_parameter("agent_id", item_index),
"llm_model_id" => exec_ctx.get_node_parameter("llm_model_id", item_index),
"prompt" => exec_ctx.get_node_parameter("prompt", item_index),
"upload_ids" => exec_ctx.get_node_parameter("upload_ids", item_index),
}
end
def runner(exec_ctx, item_index)
exec_ctx.actor_from_parameter("runner_username", item_index, default: "system")
end
def validate_mode!(mode)
return if [RUN_ONCE_FOR_ALL_ITEMS, RUN_ONCE_FOR_EACH_ITEM].include?(mode)
raise_node_error!(
I18n.t("discourse_ai.discourse_workflows.ai_agent.errors.invalid_mode", mode: mode),
)
end
def resolve_llm_model(agent_record, requested_llm_model_id)
if agent_record.force_default_llm?
llm_model =
::LlmModel.find_by(id: agent_record.default_llm_id) if agent_record.default_llm_id
return llm_model if llm_model.present?
raise_node_error!(
I18n.t(
"discourse_ai.discourse_workflows.ai_agent.errors.locked_default_llm_missing",
agent: agent_record.name,
),
)
end
if requested_llm_model_id.present?
llm_model = ::LlmModel.find_by(id: requested_llm_model_id)
return llm_model if llm_model.present?
raise_node_error!(
I18n.t(
"discourse_ai.discourse_workflows.ai_agent.errors.llm_not_found",
llm_model_id: requested_llm_model_id,
),
)
end
[agent_record.default_llm_id, SiteSetting.ai_default_llm_model].each do |llm_model_id|
llm_model = ::LlmModel.find_by(id: llm_model_id) if llm_model_id.present?
return llm_model if llm_model.present?
end
raise_node_error!(
I18n.t(
"discourse_ai.discourse_workflows.ai_agent.errors.no_llm_configured",
agent: agent_record.name,
),
)
end
def prompt_content(prompt, upload_ids, agent_record, llm_model, guardian, log)
upload_ids = filtered_upload_ids(upload_ids, agent_record, llm_model, guardian)
return prompt if upload_ids.blank?
log.info("Attachments: #{upload_ids.size} upload(s)")
[prompt, *upload_ids.map { |upload_id| { upload_id: upload_id } }]
end
def filtered_upload_ids(upload_ids, agent_record, llm_model, guardian)
upload_ids = normalize_upload_ids(upload_ids)
return [] if upload_ids.blank?
::DiscourseAi::Completions::PromptMessagesBuilder.filtered_upload_ids_for_prompt(
upload_ids,
include_image_uploads: agent_record.vision_enabled,
include_document_uploads: llm_model.allowed_attachment_types.present?,
allowed_attachment_types: llm_model.allowed_attachment_types,
guardian: guardian,
) || []
end
def normalize_upload_ids(upload_ids)
case upload_ids
when String
parsed = parse_upload_ids_json(upload_ids)
return normalize_upload_ids(parsed) if parsed
upload_ids.split(",")
when Array
upload_ids.flatten
else
Array.wrap(upload_ids)
end.filter_map do |upload_id|
id = Integer(upload_id, exception: false)
id if id&.positive?
end
end
def parse_upload_ids_json(upload_ids)
JSON.parse(upload_ids)
rescue JSON::ParserError, TypeError
nil
end
def run_agent(config, log, runner)
agent_id = config["agent_id"]
prompt = config["prompt"].to_s
agent_record = ::AiAgent.find_by(id: agent_id)
raise_node_error!("AI Agent with id #{agent_id} not found") if agent_record.nil?
if !agent_record.enabled
raise_node_error!("AI Agent '#{agent_record.name}' is disabled")
end
agent_instance = agent_record.class_instance.new
llm_model = resolve_llm_model(agent_record, config["llm_model_id"])
log.info("Agent: #{agent_record.name}")
log.info("Runner: #{runner.username}")
log.info("LLM: #{llm_model.display_name} (#{llm_model.id})")
log.info("Prompt: #{prompt.to_s[0..200]}")
bot =
DiscourseAi::Agents::Bot.as(
Discourse.system_user,
agent: agent_instance,
model: llm_model,
)
content =
prompt_content(
prompt,
config["upload_ids"],
agent_record,
llm_model,
runner.guardian,
log,
)
bot_context =
DiscourseAi::Agents::BotContext.new(
user: runner,
guardian: runner.guardian,
messages: [{ type: :user, content: content }],
feature_name: "workflow",
)
result = +""
tool_calls = 0
bot.reply(bot_context) do |partial, _, type|
if type == :tool_call
tool_calls += 1
log.info("Tool call: #{partial}") if partial.is_a?(String)
elsif type == :structured_output
result = partial.to_s
elsif type.blank?
result << partial
end
end
log.info("Tool calls: #{tool_calls}") if tool_calls > 0
log.info("Result length: #{result.size} chars")
result
end
end
end
end
end
end