Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit a316050

Browse files
committed
preserve thinking context across turns
1 parent ae3117d commit a316050

File tree

8 files changed

+151
-14
lines changed

8 files changed

+151
-14
lines changed

assets/javascripts/discourse/connectors/composer-fields/persona-llm-selector.gjs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -168,12 +168,14 @@ export default class BotSelector extends Component {
168168
.filter((bot) => !bot.is_persona)
169169
.filter(Boolean);
170170

171-
return availableBots.map((bot) => {
172-
return {
173-
id: bot.id,
174-
name: bot.display_name,
175-
};
176-
});
171+
return availableBots
172+
.map((bot) => {
173+
return {
174+
id: bot.id,
175+
name: bot.display_name,
176+
};
177+
})
178+
.sort((a, b) => a.name.localeCompare(b.name));
177179
}
178180

179181
<template>

config/locales/server.en.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ en:
261261
ai_bot:
262262
reply_error: "Sorry, it looks like our system encountered an unexpected issue while trying to reply.\n\n[details='Error details']\n%{details}\n[/details]"
263263
default_pm_prefix: "[Untitled AI bot PM]"
264+
thinking: "Thinking..."
264265
personas:
265266
default_llm_required: "Default LLM model is required prior to enabling Chat"
266267
cannot_delete_system_persona: "System personas cannot be deleted, please disable it instead"

lib/ai_bot/bot.rb

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ class Bot
77

88
BOT_NOT_FOUND = Class.new(StandardError)
99
MAX_COMPLETIONS = 5
10-
MAX_TOOLS = 5
10+
# limit is arbitrary, but 5 which was used in the past was too low
11+
MAX_TOOLS = 20
1112

1213
def self.as(bot_user, persona: DiscourseAi::AiBot::Personas::General.new, model: nil)
1314
new(bot_user, persona, model)
@@ -117,6 +118,7 @@ def reply(context, &update_blk)
117118
prompt,
118119
feature_name: "bot",
119120
partial_tool_calls: allow_partial_tool_calls,
121+
output_thinking: true,
120122
**llm_kwargs,
121123
) do |partial, cancel|
122124
tool =
@@ -158,26 +160,68 @@ def reply(context, &update_blk)
158160
if partial.is_a?(DiscourseAi::Completions::ToolCall)
159161
Rails.logger.warn("DiscourseAi: Tool not found: #{partial.name}")
160162
else
161-
update_blk.call(partial, cancel)
163+
if partial.is_a?(DiscourseAi::Completions::Thinking)
164+
if partial.partial? && partial.message.present?
165+
update_blk.call(partial.message, cancel, nil, :thinking)
166+
end
167+
if !partial.partial?
168+
# this will be dealt with later
169+
raw_context << partial
170+
end
171+
else
172+
update_blk.call(partial, cancel)
173+
end
162174
end
163175
end
164176
end
165177

166178
if !tool_found
167179
ongoing_chain = false
168-
raw_context << [result, bot_user.username]
180+
text = result
181+
182+
# we must strip out thinking
183+
if result.is_a?(Array)
184+
text = +""
185+
result.each { |item| text << item if item.is_a?(String) }
186+
end
187+
raw_context << [text, bot_user.username]
169188
end
189+
170190
total_completions += 1
171191

172192
# do not allow tools when we are at the end of a chain (total_completions == MAX_COMPLETIONS)
173193
prompt.tools = [] if total_completions == MAX_COMPLETIONS
174194
end
175195

176-
raw_context
196+
embed_thinking(raw_context)
177197
end
178198

179199
private
180200

201+
def embed_thinking(raw_context)
202+
embedded_thinking = []
203+
thinking_info = nil
204+
raw_context.each do |context|
205+
if context.is_a?(DiscourseAi::Completions::Thinking)
206+
thinking_info ||= {}
207+
if context.redacted
208+
thinking_info[:redacted_thinking_signature] = context.signature
209+
else
210+
thinking_info[:thinking] = context.message
211+
thinking_info[:thinking_signature] = context.signature
212+
end
213+
else
214+
if thinking_info
215+
context = context.dup
216+
context[4] = thinking_info
217+
end
218+
embedded_thinking << context
219+
end
220+
end
221+
222+
embedded_thinking
223+
end
224+
181225
def process_tool(tool, raw_context, llm, cancel, update_blk, prompt, context)
182226
tool_call_id = tool.tool_call_id
183227
invocation_result_json = invoke_tool(tool, llm, cancel, context, &update_blk).to_json

lib/ai_bot/playground.rb

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,9 @@ def conversation_context(post)
220220
custom_context[:id] = message[1] if custom_context[:type] != :model
221221
custom_context[:name] = message[3] if message[3]
222222

223+
thinking = message[4]
224+
custom_context[:thinking] = thinking if thinking
225+
223226
builder.push(**custom_context)
224227
end
225228
end
@@ -473,8 +476,20 @@ def reply_to(post, custom_instructions: nil, &blk)
473476

474477
post_streamer = PostStreamer.new(delay: Rails.env.test? ? 0 : 0.5) if stream_reply
475478

479+
started_thinking = false
480+
476481
new_custom_prompts =
477482
bot.reply(context) do |partial, cancel, placeholder, type|
483+
if type == :thinking && !started_thinking
484+
reply << "<details><summary>#{I18n.t("discourse_ai.ai_bot.thinking")}</summary>"
485+
started_thinking = true
486+
end
487+
488+
if type != :thinking && started_thinking
489+
reply << "</details>\n\n"
490+
started_thinking = false
491+
end
492+
478493
reply << partial
479494
raw = reply.dup
480495
raw << "\n\n" << placeholder if placeholder.present?
@@ -527,8 +542,10 @@ def reply_to(post, custom_instructions: nil, &blk)
527542
)
528543
end
529544

530-
# we do not need to add a custom prompt for a single reply
531-
if new_custom_prompts.length > 1
545+
# a bit messy internally, but this is how we tell
546+
is_thinking = new_custom_prompts.any? { |prompt| prompt[4].present? }
547+
548+
if is_thinking || new_custom_prompts.length > 1
532549
reply_post.post_custom_prompt ||= reply_post.build_post_custom_prompt(custom_prompt: [])
533550
prompt = reply_post.post_custom_prompt.custom_prompt || []
534551
prompt.concat(new_custom_prompts)

lib/completions/endpoints/canned_response.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ def perform_completion!(
2929
model_params,
3030
feature_name: nil,
3131
feature_context: nil,
32-
partial_tool_calls: false
32+
partial_tool_calls: false,
33+
output_thinking: false
3334
)
3435
@dialect = dialect
3536
@model_params = model_params
@@ -51,6 +52,8 @@ def perform_completion!(
5152
as_array.each do |response|
5253
if is_tool?(response)
5354
yield(response, cancel_fn)
55+
elsif is_thinking?(response)
56+
yield(response, cancel_fn)
5457
else
5558
response.each_char do |char|
5659
break if cancelled
@@ -70,6 +73,10 @@ def tokenizer
7073

7174
private
7275

76+
def is_thinking?(response)
77+
response.is_a?(DiscourseAi::Completions::Thinking)
78+
end
79+
7380
def is_tool?(response)
7481
response.is_a?(DiscourseAi::Completions::ToolCall)
7582
end

lib/completions/prompt_messages_builder.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ def to_a(limit: nil, style: nil)
102102
end
103103
end
104104

105-
def push(type:, content:, name: nil, upload_ids: nil, id: nil)
105+
def push(type:, content:, name: nil, upload_ids: nil, id: nil, thinking: nil)
106106
if !%i[user model tool tool_call system].include?(type)
107107
raise ArgumentError, "type must be either :user, :model, :tool, :tool_call or :system"
108108
end
@@ -112,6 +112,15 @@ def push(type:, content:, name: nil, upload_ids: nil, id: nil)
112112
message[:name] = name.to_s if name
113113
message[:upload_ids] = upload_ids if upload_ids
114114
message[:id] = id.to_s if id
115+
if thinking
116+
message[:thinking] = thinking["thinking"] if thinking["thinking"]
117+
message[:thinking_signature] = thinking["thinking_signature"] if thinking[
118+
"thinking_signature"
119+
]
120+
message[:redacted_thinking_signature] = thinking[
121+
"redacted_thinking_signature"
122+
] if thinking["redacted_thinking_signature"]
123+
end
115124

116125
@raw_messages << message
117126
end

lib/completions/thinking.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ def initialize(message:, signature: nil, redacted: false, partial: false)
1212
@partial = partial
1313
end
1414

15+
def partial?
16+
!!@partial
17+
end
18+
1519
def ==(other)
1620
message == other.message && signature == other.signature && redacted == other.redacted &&
1721
partial == other.partial

spec/lib/modules/ai_bot/playground_spec.rb

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,59 @@
828828
end
829829

830830
describe "#reply_to" do
831+
it "preserves thinking context between replies" do
832+
thinking_progress =
833+
DiscourseAi::Completions::Thinking.new(message: "I should say hello", partial: true)
834+
thinking =
835+
DiscourseAi::Completions::Thinking.new(
836+
message: "I should say hello",
837+
signature: "thinking-signature-123",
838+
partial: false,
839+
)
840+
841+
thinking_redacted =
842+
DiscourseAi::Completions::Thinking.new(
843+
message: nil,
844+
signature: "thinking-redacted-signature-123",
845+
partial: false,
846+
redacted: true,
847+
)
848+
849+
first_responses = [[thinking_progress, thinking, thinking_redacted, "Hello Sam"]]
850+
851+
DiscourseAi::Completions::Llm.with_prepared_responses(first_responses) do
852+
playground.reply_to(third_post)
853+
end
854+
855+
new_post = third_post.topic.reload.posts.order(:post_number).last
856+
expect(new_post.raw).to include("Hello Sam")
857+
expect(new_post.raw).to include("I should say hello")
858+
859+
post = Fabricate(:post, topic: third_post.topic, user: user, raw: "Say Cat")
860+
861+
prompt_detail = nil
862+
# Capture the prompt to verify thinking context was included
863+
DiscourseAi::Completions::Llm.with_prepared_responses(["Cat"]) do |_, _, prompts|
864+
playground.reply_to(post)
865+
prompt_detail = prompts.first
866+
end
867+
868+
last_messages = prompt_detail.messages.last(2)
869+
870+
expect(last_messages).to eq(
871+
[
872+
{
873+
type: :model,
874+
content: "Hello Sam",
875+
thinking: "I should say hello",
876+
thinking_signature: "thinking-signature-123",
877+
redacted_thinking_signature: "thinking-redacted-signature-123",
878+
},
879+
{ type: :user, content: "Say Cat", id: "bruce1" },
880+
],
881+
)
882+
end
883+
831884
it "streams the bot reply through MB and create a new post in the PM with a cooked responses" do
832885
expected_bot_response =
833886
"Hello this is a bot and what you just said is an interesting question"

0 commit comments

Comments
 (0)