mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-06-19 04:03:45 +08:00
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>
509 lines
15 KiB
Ruby
Vendored
509 lines
15 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
RSpec.describe AiAgent do
|
|
subject(:basic_agent) do
|
|
AiAgent.new(
|
|
name: "test",
|
|
description: "test",
|
|
system_prompt: "test",
|
|
tools: [],
|
|
allowed_group_ids: [],
|
|
)
|
|
end
|
|
|
|
fab!(:llm_model)
|
|
fab!(:seeded_llm_model) { Fabricate(:llm_model, id: -1) }
|
|
|
|
before { enable_current_plugin }
|
|
|
|
it "validates context settings" do
|
|
expect(basic_agent.valid?).to eq(true)
|
|
|
|
basic_agent.max_context_posts = 0
|
|
expect(basic_agent.valid?).to eq(false)
|
|
expect(basic_agent.errors[:max_context_posts]).to eq(["must be greater than 0"])
|
|
|
|
basic_agent.max_context_posts = 1
|
|
expect(basic_agent.valid?).to eq(true)
|
|
|
|
basic_agent.max_context_posts = nil
|
|
expect(basic_agent.valid?).to eq(true)
|
|
end
|
|
|
|
it "validates tools" do
|
|
Fabricate(:ai_tool, id: 1)
|
|
Fabricate(:ai_tool, id: 2, name: "Archie search", tool_name: "search")
|
|
|
|
expect(basic_agent.valid?).to eq(true)
|
|
|
|
basic_agent.tools = %w[search image_generation]
|
|
expect(basic_agent.valid?).to eq(true)
|
|
|
|
basic_agent.tools = %w[search image_generation search]
|
|
expect(basic_agent.valid?).to eq(false)
|
|
expect(basic_agent.errors[:tools]).to eq(["Can not have duplicate tools"])
|
|
|
|
basic_agent.tools = [
|
|
["custom-1", { test: "test" }, false],
|
|
["custom-2", { test: "test" }, false],
|
|
]
|
|
expect(basic_agent.valid?).to eq(true)
|
|
expect(basic_agent.errors[:tools]).to eq([])
|
|
|
|
basic_agent.tools = [
|
|
["custom-1", { test: "test" }, false],
|
|
["custom-1", { test: "test" }, false],
|
|
]
|
|
expect(basic_agent.valid?).to eq(false)
|
|
expect(basic_agent.errors[:tools]).to eq(["Can not have duplicate tools"])
|
|
|
|
basic_agent.tools = [
|
|
["custom-1", { test: "test" }, false],
|
|
["custom-2", { test: "test" }, false],
|
|
"image_generation",
|
|
]
|
|
expect(basic_agent.valid?).to eq(true)
|
|
expect(basic_agent.errors[:tools]).to eq([])
|
|
|
|
basic_agent.tools = [
|
|
["custom-1", { test: "test" }, false],
|
|
["custom-2", { test: "test" }, false],
|
|
"Search",
|
|
]
|
|
expect(basic_agent.valid?).to eq(false)
|
|
expect(basic_agent.errors[:tools]).to eq(["Can not have duplicate tools"])
|
|
end
|
|
|
|
describe "provider-native tools" do
|
|
fab!(:gemini_model)
|
|
fab!(:openai_chat_model) do
|
|
Fabricate(:llm_model, url: "https://api.openai.com/v1/chat/completions")
|
|
end
|
|
|
|
it "requires a forced default LLM that supports the native tool" do
|
|
basic_agent.tools = ["native-web_search"]
|
|
|
|
# no forced default LLM
|
|
expect(basic_agent.valid?).to eq(false)
|
|
expect(basic_agent.errors[:tools]).to include(
|
|
I18n.t("discourse_ai.ai_bot.agents.native_tool_requires_forced_llm"),
|
|
)
|
|
|
|
# forced LLM whose provider does not support web search (chat completions)
|
|
basic_agent.default_llm = openai_chat_model
|
|
basic_agent.force_default_llm = true
|
|
expect(basic_agent.valid?).to eq(false)
|
|
expect(basic_agent.errors[:tools]).to include(
|
|
I18n.t("discourse_ai.ai_bot.agents.native_tool_unsupported_by_llm"),
|
|
)
|
|
|
|
# forced LLM that supports web search
|
|
basic_agent.default_llm = gemini_model
|
|
expect(basic_agent.valid?).to eq(true)
|
|
end
|
|
end
|
|
|
|
it "allows creation of user" do
|
|
user = basic_agent.create_user!
|
|
expect(user.username).to eq("test_bot")
|
|
expect(user.name).to eq("Test")
|
|
expect(user.bot?).to be(true)
|
|
expect(user.id).to be <= AiAgent::FIRST_AGENT_USER_ID
|
|
end
|
|
|
|
it "removes all rag embeddings when rag params change" do
|
|
agent =
|
|
AiAgent.create!(
|
|
name: "test",
|
|
description: "test",
|
|
system_prompt: "test",
|
|
tools: [],
|
|
allowed_group_ids: [],
|
|
rag_chunk_tokens: 10,
|
|
rag_chunk_overlap_tokens: 5,
|
|
)
|
|
|
|
id =
|
|
RagDocumentFragment.create!(
|
|
target: agent,
|
|
fragment: "test",
|
|
fragment_number: 1,
|
|
upload: Fabricate(:upload),
|
|
).id
|
|
|
|
agent.rag_chunk_tokens = 20
|
|
agent.save!
|
|
|
|
expect(RagDocumentFragment.exists?(id)).to eq(false)
|
|
end
|
|
|
|
it "defines singleton methods on system agent classes" do
|
|
forum_helper = AiAgent.find_by(name: "Forum Helper")
|
|
forum_helper.update!(
|
|
user_id: 1,
|
|
default_llm_id: llm_model.id,
|
|
max_context_posts: 3,
|
|
allow_topic_mentions: true,
|
|
allow_personal_messages: true,
|
|
allow_chat_channel_mentions: true,
|
|
allow_chat_direct_messages: true,
|
|
)
|
|
|
|
klass = forum_helper.class_instance
|
|
|
|
expect(klass.id).to eq(forum_helper.id)
|
|
expect(klass.system).to eq(true)
|
|
# tl 0 by default
|
|
expect(klass.allowed_group_ids).to eq([10])
|
|
expect(klass.user_id).to eq(1)
|
|
expect(klass.default_llm_id).to eq(llm_model.id)
|
|
expect(klass.max_context_posts).to eq(3)
|
|
expect(klass.allow_topic_mentions).to eq(true)
|
|
expect(klass.allow_personal_messages).to eq(true)
|
|
expect(klass.allow_chat_channel_mentions).to eq(true)
|
|
expect(klass.allow_chat_direct_messages).to eq(true)
|
|
end
|
|
|
|
it "defines singleton methods non agent classes" do
|
|
agent =
|
|
AiAgent.create!(
|
|
name: "test",
|
|
description: "test",
|
|
system_prompt: "test",
|
|
tools: [],
|
|
allowed_group_ids: [],
|
|
default_llm_id: llm_model.id,
|
|
max_context_posts: 3,
|
|
allow_topic_mentions: true,
|
|
allow_personal_messages: true,
|
|
allow_chat_channel_mentions: true,
|
|
allow_chat_direct_messages: true,
|
|
user_id: 1,
|
|
)
|
|
|
|
klass = agent.class_instance
|
|
|
|
expect(klass.id).to eq(agent.id)
|
|
expect(klass.system).to eq(false)
|
|
expect(klass.allowed_group_ids).to eq([])
|
|
expect(klass.user_id).to eq(1)
|
|
expect(klass.default_llm_id).to eq(llm_model.id)
|
|
expect(klass.max_context_posts).to eq(3)
|
|
expect(klass.allow_topic_mentions).to eq(true)
|
|
expect(klass.allow_personal_messages).to eq(true)
|
|
expect(klass.allow_chat_channel_mentions).to eq(true)
|
|
expect(klass.allow_chat_direct_messages).to eq(true)
|
|
end
|
|
|
|
it "attaches mcp tool classes for assigned servers" do
|
|
ai_mcp_server = Fabricate(:ai_mcp_server, name: "Jira")
|
|
agent =
|
|
AiAgent.create!(
|
|
name: "mcp_agent",
|
|
description: "test",
|
|
system_prompt: "test",
|
|
tools: [],
|
|
allowed_group_ids: [],
|
|
)
|
|
agent.ai_mcp_servers << ai_mcp_server
|
|
|
|
DiscourseAi::Mcp::ToolRegistry.stubs(:tool_classes_for_servers).returns(
|
|
[
|
|
DiscourseAi::Agents::Tools::Mcp.class_instance(
|
|
ai_mcp_server.id,
|
|
"search_issues",
|
|
{ "name" => "search_issues", "description" => "Search issues", "inputSchema" => {} },
|
|
),
|
|
],
|
|
)
|
|
|
|
klass = agent.class_instance
|
|
|
|
expect(klass.new.tools.map { |tool| tool.signature[:name] }).to include("search_issues")
|
|
end
|
|
|
|
it "passes selected MCP tool names to the registry" do
|
|
ai_mcp_server = Fabricate(:ai_mcp_server, name: "Jira")
|
|
agent =
|
|
AiAgent.create!(
|
|
name: "mcp_agent",
|
|
description: "test",
|
|
system_prompt: "test",
|
|
tools: [],
|
|
allowed_group_ids: [],
|
|
)
|
|
agent.ai_mcp_servers << ai_mcp_server
|
|
agent
|
|
.ai_agent_mcp_servers
|
|
.find_by!(ai_mcp_server_id: ai_mcp_server.id)
|
|
.update!(selected_tool_names: ["search_issues"])
|
|
|
|
DiscourseAi::Mcp::ToolRegistry
|
|
.expects(:tool_classes_for_servers)
|
|
.with(
|
|
[ai_mcp_server],
|
|
reserved_names: [],
|
|
selected_tool_names_by_server: {
|
|
ai_mcp_server.id => ["search_issues"],
|
|
},
|
|
)
|
|
.returns([])
|
|
|
|
agent.class_instance
|
|
end
|
|
|
|
it "does not allow setting allowing chat without a default_llm" do
|
|
agent =
|
|
AiAgent.create(
|
|
name: "test",
|
|
description: "test",
|
|
system_prompt: "test",
|
|
allowed_group_ids: [],
|
|
default_llm: nil,
|
|
allow_chat_channel_mentions: true,
|
|
)
|
|
|
|
expect(agent.valid?).to eq(false)
|
|
expect(agent.errors[:base]).to include(
|
|
I18n.t("discourse_ai.ai_bot.agents.default_llm_required"),
|
|
)
|
|
|
|
agent =
|
|
AiAgent.create(
|
|
name: "test",
|
|
description: "test",
|
|
system_prompt: "test",
|
|
allowed_group_ids: [],
|
|
default_llm: nil,
|
|
allow_chat_direct_messages: true,
|
|
)
|
|
|
|
expect(agent.valid?).to eq(false)
|
|
expect(agent.errors[:base]).to include(
|
|
I18n.t("discourse_ai.ai_bot.agents.default_llm_required"),
|
|
)
|
|
|
|
agent =
|
|
AiAgent.create(
|
|
name: "test",
|
|
description: "test",
|
|
system_prompt: "test",
|
|
allowed_group_ids: [],
|
|
default_llm: nil,
|
|
allow_topic_mentions: true,
|
|
)
|
|
|
|
expect(agent.valid?).to eq(false)
|
|
expect(agent.errors[:base]).to include(
|
|
I18n.t("discourse_ai.ai_bot.agents.default_llm_required"),
|
|
)
|
|
end
|
|
|
|
it "does not leak caches between sites" do
|
|
AiAgent.create!(
|
|
name: "pun_bot",
|
|
description: "you write puns",
|
|
system_prompt: "you are pun bot",
|
|
tools: ["ImageCommand"],
|
|
allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]],
|
|
)
|
|
|
|
AiAgent.all_agents
|
|
|
|
expect(AiAgent.agent_cache[:value].length).to be > 0
|
|
RailsMultisite::ConnectionManagement.stubs(:current_db) { "abc" }
|
|
expect(AiAgent.agent_cache[:value]).to eq(nil)
|
|
end
|
|
|
|
describe ".find_by_id_from_cache" do
|
|
fab!(:agent) do
|
|
AiAgent.create!(
|
|
name: "cached_agent",
|
|
description: "test agent for cache",
|
|
system_prompt: "you are a test",
|
|
tools: [],
|
|
allowed_group_ids: [],
|
|
)
|
|
end
|
|
|
|
it "returns nil for blank agent_id" do
|
|
expect(AiAgent.find_by_id_from_cache(nil)).to eq(nil)
|
|
expect(AiAgent.find_by_id_from_cache("")).to eq(nil)
|
|
end
|
|
|
|
it "finds agent by id from cache" do
|
|
result = AiAgent.find_by_id_from_cache(agent.id)
|
|
expect(result).to be_present
|
|
expect(result.id).to eq(agent.id)
|
|
expect(result.name).to eq("cached_agent")
|
|
end
|
|
|
|
it "finds agent when id is provided as a string" do
|
|
result = AiAgent.find_by_id_from_cache(agent.id.to_s)
|
|
expect(result).to be_present
|
|
expect(result.id).to eq(agent.id)
|
|
end
|
|
|
|
it "returns nil for non-existent agent id" do
|
|
result = AiAgent.find_by_id_from_cache(999_999)
|
|
expect(result).to eq(nil)
|
|
end
|
|
|
|
it "finds disabled agents" do
|
|
agent.update!(enabled: false)
|
|
result = AiAgent.find_by_id_from_cache(agent.id)
|
|
expect(result).to be_present
|
|
expect(result.id).to eq(agent.id)
|
|
end
|
|
|
|
it "uses cache and avoids database queries after initial load" do
|
|
AiAgent.find_by_id_from_cache(agent.id)
|
|
|
|
query_count = track_sql_queries { AiAgent.find_by_id_from_cache(agent.id) }.count
|
|
|
|
expect(query_count).to eq(0)
|
|
end
|
|
|
|
it "falls back to database when cache is cleared after initial load" do
|
|
result_before = AiAgent.find_by_id_from_cache(agent.id)
|
|
expect(result_before).to be_present
|
|
|
|
AiAgent.agent_cache.flush!
|
|
|
|
query_count =
|
|
track_sql_queries do
|
|
result_after = AiAgent.find_by_id_from_cache(agent.id)
|
|
expect(result_after).to be_present
|
|
expect(result_after.id).to eq(agent.id)
|
|
expect(result_after.name).to eq("cached_agent")
|
|
end.count
|
|
|
|
expect(query_count).to be > 0
|
|
end
|
|
end
|
|
|
|
describe "system agent validations" do
|
|
let(:system_agent) do
|
|
AiAgent.create!(
|
|
name: "system_agent",
|
|
description: "system agent",
|
|
system_prompt: "system agent",
|
|
tools: %w[Search Time],
|
|
response_format: [{ key: "summary", type: "string" }],
|
|
examples: [%w[user_msg1 assistant_msg1], %w[user_msg2 assistant_msg2]],
|
|
system: true,
|
|
)
|
|
end
|
|
|
|
context "when modifying a system agent" do
|
|
it "allows changing tool options without allowing tool additions/removals" do
|
|
tools = [["Search", { "base_query" => "abc" }], ["Time"]]
|
|
system_agent.update!(tools: tools)
|
|
|
|
system_agent.reload
|
|
expect(system_agent.tools).to eq(tools)
|
|
|
|
invalid_tools = ["Time"]
|
|
system_agent.update(tools: invalid_tools)
|
|
expect(system_agent.errors[:base]).to include(
|
|
I18n.t("discourse_ai.ai_bot.agents.cannot_edit_system_agent"),
|
|
)
|
|
end
|
|
|
|
it "doesn't accept response format changes" do
|
|
new_format = [{ key: "summary2", type: "string" }]
|
|
|
|
expect { system_agent.update!(response_format: new_format) }.to raise_error(
|
|
ActiveRecord::RecordInvalid,
|
|
)
|
|
end
|
|
|
|
it "doesn't accept additional format changes" do
|
|
new_format = [{ key: "summary", type: "string" }, { key: "summary2", type: "string" }]
|
|
|
|
expect { system_agent.update!(response_format: new_format) }.to raise_error(
|
|
ActiveRecord::RecordInvalid,
|
|
)
|
|
end
|
|
|
|
it "doesn't accept changes to examples" do
|
|
other_examples = [%w[user_msg1 assistant_msg1]]
|
|
|
|
expect { system_agent.update!(examples: other_examples) }.to raise_error(
|
|
ActiveRecord::RecordInvalid,
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "agentic execution mode defaults and propagation" do
|
|
it "assigns safe defaults to a agent created without agentic fields" do
|
|
agent =
|
|
AiAgent.create!(
|
|
name: "legacy_agent",
|
|
description: "no agentic fields set",
|
|
system_prompt: "test",
|
|
tools: [],
|
|
allowed_group_ids: [],
|
|
)
|
|
|
|
expect(agent.execution_mode).to eq("default")
|
|
expect(agent.max_turn_tokens).to be_nil
|
|
expect(agent.compression_threshold).to be_nil
|
|
|
|
klass = agent.class_instance
|
|
|
|
expect(klass.execution_mode).to eq("default")
|
|
expect(klass.max_turn_tokens).to be_nil
|
|
expect(klass.compression_threshold).to be_nil
|
|
end
|
|
|
|
it "requires compression_threshold for agentic mode but not for default mode" do
|
|
agent =
|
|
AiAgent.new(
|
|
name: "agentic_agent",
|
|
description: "test",
|
|
system_prompt: "test",
|
|
tools: [],
|
|
allowed_group_ids: [],
|
|
execution_mode: "agentic",
|
|
)
|
|
|
|
expect(agent.valid?).to eq(false)
|
|
expect(agent.errors[:compression_threshold]).to be_present
|
|
|
|
agent.compression_threshold = 75
|
|
expect(agent.valid?).to eq(true)
|
|
|
|
agent.execution_mode = "default"
|
|
agent.compression_threshold = nil
|
|
expect(agent.valid?).to eq(true)
|
|
end
|
|
end
|
|
|
|
describe "validates examples format" do
|
|
it "doesn't accept examples that are not arrays" do
|
|
basic_agent.examples = [1]
|
|
|
|
expect(basic_agent.valid?).to eq(false)
|
|
expect(basic_agent.errors[:examples].first).to eq(
|
|
I18n.t("discourse_ai.agents.malformed_examples"),
|
|
)
|
|
end
|
|
|
|
it "doesn't accept examples that don't come in pairs" do
|
|
basic_agent.examples = [%w[user_msg1]]
|
|
|
|
expect(basic_agent.valid?).to eq(false)
|
|
expect(basic_agent.errors[:examples].first).to eq(
|
|
I18n.t("discourse_ai.agents.malformed_examples"),
|
|
)
|
|
end
|
|
|
|
it "works when example is well formatted" do
|
|
basic_agent.examples = [%w[user_msg1 assistant1]]
|
|
|
|
expect(basic_agent.valid?).to eq(true)
|
|
end
|
|
end
|
|
end
|