0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-08 17:53:55 +08:00
discourse/plugins/discourse-ai/spec/models/ai_agent_spec.rb
Sam f86bdd738a
DEV: Make AI context compaction global (#41317)
Apply token-budgeted agent execution and context compaction to all AI
agent runs instead of gating it behind execution_mode. Remove the legacy
fixed-limit agent settings, keep compression_threshold defaulted to 80,
and trim prompt history from the latest compression checkpoint.

Why:
Preserving a stable compressed context across turns keeps important
conversation state available while allowing newer messages to append to
a
consistent prefix. That improves cache reuse and avoids repeatedly
throwing away useful context just to stay under model limits.

Compaction shape:

  before: turn 1 [full -> compact] | turn 2 [full -> compact]
  after:  [compact checkpoint] -> turn 1 -> turn 2 -> ...

---------

Co-authored-by: Rafael Silva <xfalcox@gmail.com>
2026-07-14 18:41:37 -03:00

530 lines
16 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 "exposes system agent thinking effort on class instances" do
agent_record =
AiAgent.find(DiscourseAi::Agents::Agent.system_agents[DiscourseAi::Agents::Creative])
agent_record.update!(thinking_effort: "max")
agent = agent_record.class_instance.new
expect(agent.thinking_effort).to eq("max")
end
it "declares a default thinking effort for reasoning-enabled system agents" do
{
DiscourseAi::Agents::Creative => "low",
DiscourseAi::Agents::General => "low",
DiscourseAi::Agents::DiscourseHelper => "low",
DiscourseAi::Agents::SqlHelper => "medium",
DiscourseAi::Agents::ForumResearcher => "high",
}.each do |klass, effort|
expect(klass.new.thinking_effort).to eq(effort),
"expected #{klass} to default to #{effort.inspect} thinking effort, got #{klass.new.thinking_effort.inspect}"
end
end
it "seeds the default thinking effort on deploy without clobbering admin choices" do
creative_id = DiscourseAi::Agents::Agent.system_agents[DiscourseAi::Agents::Creative]
general_id = DiscourseAi::Agents::Agent.system_agents[DiscourseAi::Agents::General]
# an agent that was never configured, and one an admin has customized
AiAgent.where(id: creative_id).update_all(thinking_effort: nil)
AiAgent.where(id: general_id).update_all(thinking_effort: "high")
# load (not require_relative) so the seeding script actually re-executes here
load Rails.root.join("plugins/discourse-ai/db/fixtures/agents/603_ai_agents.rb") # rubocop:disable Discourse/Plugins/UseRequireRelative
expect(AiAgent.find(creative_id).thinking_effort).to eq("low") # seeded default
expect(AiAgent.find(general_id).thinking_effort).to eq("high") # admin choice preserved
end
it "clears AI helper prompt permissions after changes" do
agent = AiAgent.find(SiteSetting.ai_helper_proofreader_agent)
DiscourseAi::AiHelper::Assistant.prompt_cache[:value] = "cached prompts"
agent.update!(allowed_group_ids: [Group::AUTO_GROUPS[:staff]])
expect(DiscourseAi::AiHelper::Assistant.prompt_cache[:value]).to be_nil
end
it "keeps AI helper prompt permissions after unrelated changes" do
agent =
AiAgent.create!(
name: "unrelated agent",
description: "test",
system_prompt: "test",
tools: [],
allowed_group_ids: [],
)
DiscourseAi::AiHelper::Assistant.prompt_cache[:value] = "cached prompts"
agent.update!(allowed_group_ids: [Group::AUTO_GROUPS[:staff]])
expect(DiscourseAi::AiHelper::Assistant.prompt_cache[:value]).to eq("cached prompts")
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,
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.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,
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.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 "token budget settings" do
it "allows an agent to use the default token budget" do
agent =
AiAgent.create!(
name: "token_budget_agent",
description: "test",
system_prompt: "test",
tools: [],
allowed_group_ids: [],
)
expect(agent.max_turn_tokens).to be_nil
expect(agent.compression_threshold).to eq(80)
klass = agent.class_instance
expect(klass.max_turn_tokens).to be_nil
expect(klass.compression_threshold).to eq(80)
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