FIX: Keep AI bot PM titles working once the bot's edit budget is spent (#42523)

Previously, automatic AI bot conversation titles were saved through
`PostRevisor` without bypassing the edit rate limiter, so each one
counted against the bot account's daily allowance. Agent bot users are
TL4 but not staff, so unlike the system user they are not exempt — once
a busy bot spent its allowance the limiter raised inside the enclosing
transaction, the title write was rolled back, and conversations were
left as "[Untitled AI bot PM]" while replies carried on working.
Reported by a customer who noticed exactly `max_edits_per_day` × the TL4
multiplier successful titles in a day, and none after.

This change passes `bypass_rate_limiter` on the title and
regenerate-reply paths, moves `bypass_bump`/`skip_validations` in the
LLM tagger and triage automations out of the fields hash and into the
options hash where `PostRevisor` actually reads them, and makes title
generation resilient to an empty or over-long model response by falling
back to an excerpt of the member's own first post, truncating to a valid
length, and only announcing the title over MessageBus once the save
succeeded.
This commit is contained in:
Régis Hanol
2026-08-12 09:12:06 +02:00
committed by GitHub
parent ed2b5b2f80
commit e971bdff54
7 changed files with 123 additions and 18 deletions
+30 -5
View File
@@ -8,6 +8,7 @@ module DiscourseAi
# 10 minutes is enough for vast majority of cases
# there is a small chance that some reasoning models may take longer
MAX_STREAM_DELAY_SECONDS = 600
FALLBACK_TITLE_LENGTH = 80
attr_reader :bot
@@ -295,12 +296,33 @@ module DiscourseAi
DiscourseAi::Completions::Llm.text_from_response(
bot.llm.generate(title_prompt, user: user, feature_name: "bot_title"),
)
new_title = new_title.strip.split("\n").last
new_title = new_title.to_s.strip.split("\n").last.to_s
new_title = new_title.delete_prefix('"').delete_suffix('"')
PostRevisor.new(post.topic.first_post, post.topic).revise!(
bot.bot_user,
title: new_title.sub(/\A"/, "").sub(/"\Z/, ""),
)
first_post = post.topic.first_post
if new_title.blank?
new_title =
PrettyText.excerpt(
first_post.cooked,
FALLBACK_TITLE_LENGTH,
strip_links: true,
text_entities: true,
)
end
return if new_title.blank?
new_title = new_title.truncate(SiteSetting.max_topic_title_length, separator: /\s/)
revised =
PostRevisor.new(first_post, post.topic).revise!(
bot.bot_user,
{ title: new_title },
bypass_rate_limiter: true,
)
return if !revised
allowed_users = post.topic.topic_allowed_users.pluck(:user_id)
MessageBus.publish(
@@ -313,6 +335,8 @@ module DiscourseAi
{ title: post.topic.title, topic_id: post.topic.id },
user_ids: allowed_users,
)
rescue StandardError => e
Discourse.warn_exception(e, message: "Discourse AI: Unable to generate title")
end
def reply_to_chat_message(message, channel, context_post_ids)
@@ -689,6 +713,7 @@ module DiscourseAi
{ raw: reply },
skip_validations: true,
force_new_version: true,
bypass_rate_limiter: true,
)
save_ai_custom_fields(reply_post, authorization_user_id: authorization_user_id)
else
@@ -543,11 +543,7 @@ module DiscourseAi
agent_class = DiscourseAi::Agents::Agent.find_by(id: @agent.id, user: @current_user)
if agent_class
bot = DiscourseAi::Agents::Bot.as(@reply_user, agent: agent_class.new, model: llm_model)
begin
DiscourseAi::AiBot::Playground.new(bot).title_playground(reply_post, @user)
rescue StandardError => e
Discourse.warn_exception(e, message: "Discourse AI: Unable to generate stream title")
end
DiscourseAi::AiBot::Playground.new(bot).title_playground(reply_post, @user)
end
end
end
@@ -189,9 +189,12 @@ module DiscourseAi
first_post = topic.posts.where(post_number: 1).first
return unless first_post
changes = { tags: all_tags, bypass_bump: true, skip_validations: true }
first_post.revise(Discourse.system_user, changes)
first_post.revise(
Discourse.system_user,
{ tags: all_tags },
bypass_bump: true,
skip_validations: true,
)
end
end
end
@@ -216,9 +216,12 @@ module DiscourseAi
if changes.present?
first_post = post.topic.posts.where(post_number: 1).first
changes[:bypass_bump] = true
changes[:skip_validations] = true
first_post.revise(Discourse.system_user, changes)
first_post.revise(
Discourse.system_user,
changes,
bypass_bump: true,
skip_validations: true,
)
end
post.topic.update!(visible: false) if hide_topic
@@ -66,8 +66,9 @@ RSpec.describe DiscourseAi::Automation::LlmTagger do
Tag.find_by(name: "question")&.update!(public_topic_count: 1)
end
it "processes a post and applies appropriate tags" do
it "processes a post and applies appropriate tags without bumping the topic" do
mock_response = { "tags" => ["bug"], "confidence" => 90 }.to_json
bumped_at = topic.bumped_at
DiscourseAi::Completions::Llm.with_prepared_responses([mock_response]) do
described_class.handle(
@@ -83,6 +84,7 @@ RSpec.describe DiscourseAi::Automation::LlmTagger do
end
expect(topic.reload.tags.map(&:name)).to include("bug")
expect(topic.bumped_at).to eq_time(bumped_at)
end
it "includes document uploads independently from image uploads" do
@@ -1131,6 +1131,80 @@ RSpec.describe DiscourseAi::AiBot::Playground do
expect(pm.reload.title).to eq(expected_response)
end
end
it "falls back to an excerpt of the first post when the model returns nothing" do
DiscourseAi::Completions::Llm.with_prepared_responses([""]) do
playground.title_playground(third_post, user)
end
expect(pm.reload.title).to eq(first_post.raw)
end
it "truncates a title the model returns too long to be valid" do
long_title = "word " * 100
DiscourseAi::Completions::Llm.with_prepared_responses([long_title]) do
playground.title_playground(third_post, user)
end
expect(pm.reload.title.length).to be <= SiteSetting.max_topic_title_length
expect(pm.reload.title.downcase).to start_with("word word")
end
it "does not announce a title that was not saved" do
PostRevisor.any_instance.stubs(:revise!).returns(false)
messages =
MessageBus.track_publish("/discourse-ai/ai-bot/topic-titles") do
DiscourseAi::Completions::Llm.with_prepared_responses([expected_response]) do
playground.title_playground(third_post, user)
end
end
expect(messages).to be_empty
expect(pm.reload.title).to eq("This is my special PM")
end
it "logs and swallows errors instead of propagating them to the caller" do
PostRevisor.any_instance.stubs(:revise!).raises(StandardError.new("boom"))
Discourse.expects(:warn_exception).once
DiscourseAi::Completions::Llm.with_prepared_responses([expected_response]) do
expect { playground.title_playground(third_post, user) }.to_not raise_error
end
end
context "when the bot user's daily edit allowance is exhausted" do
fab!(:agent) { Fabricate(:ai_agent, enabled: false) }
fab!(:agent_user) { agent.create_user! }
let(:agent_playground) do
described_class.new(
DiscourseAi::Agents::Bot.as(agent_user, agent: agent.class_instance.new, model: claude_2),
)
end
before do
RateLimiter.enable
SiteSetting.editing_grace_period = 0
SiteSetting.max_edits_per_day = 1
SiteSetting.tl4_additional_edits_per_day_multiplier = 1
end
it "still updates the title" do
expect(agent_user.trust_level).to eq(TrustLevel[4])
expect(agent_user.staff?).to eq(false)
scratch_post = Fabricate(:post, user: agent_user)
PostRevisor.new(scratch_post).revise!(agent_user, raw: "using up the daily allowance")
DiscourseAi::Completions::Llm.with_prepared_responses([expected_response]) do
agent_playground.title_playground(third_post, user)
end
expect(pm.reload.title).to eq(expected_response)
end
end
end
describe "#reply_to" do
@@ -43,8 +43,9 @@ describe DiscourseAi::Automation::LlmTriage do
expect(post.topic.reload.visible).to eq(false)
end
it "can categorize topics on triage" do
it "can categorize topics on triage without bumping the topic" do
category = Fabricate(:category)
bumped_at = post.topic.bumped_at
DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do
triage(
@@ -57,6 +58,7 @@ describe DiscourseAi::Automation::LlmTriage do
end
expect(post.topic.reload.category_id).to eq(category.id)
expect(post.topic.bumped_at).to eq_time(bumped_at)
end
it "can reply to topics on triage" do