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

Commit e159840

Browse files
authored
FEATURE: allow tools to amend personas (#1250)
Add API methods to AI tools for reading and updating personas, enabling more flexible AI workflows. This allows custom tools to: - Fetch persona information through discourse.getPersona() - Update personas with modified settings via discourse.updatePersona() - Also update using persona.update() These APIs enable new use cases like "trainable" moderation bots, where users with appropriate permissions can set and refine moderation rules through direct chat interactions, without needing admin panel access. Also adds a special API scope which allows people to lean on API for similar actions Additionally adds a rather powerful hidden feature can allow custom tools to inject content into the context unconditionally it can be used for memory and similar features
1 parent 0e4bf29 commit e159840

File tree

8 files changed

+370
-5
lines changed

8 files changed

+370
-5
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@ node_modules
55
evals/log
66
evals/cases
77
config/eval-llms.local.yml
8+
# this gets rid of search results from ag, ripgrep, etc
9+
tokenizers/
10+
public/ai-share/highlight.min.js

config/locales/client.en.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ en:
77
discourse_ai:
88
search: "Allows AI search"
99
stream_completion: "Allows streaming AI persona completions"
10+
update_personas: "Allows updating AI personas"
1011

1112
site_settings:
1213
categories:

lib/personas/tool_runner.rb

Lines changed: 128 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,19 +82,39 @@ def framework_script
8282
search: function(params) {
8383
return _discourse_search(params);
8484
},
85+
updatePersona: function(persona_id_or_name, updates) {
86+
const result = _discourse_update_persona(persona_id_or_name, updates);
87+
if (result.error) {
88+
throw new Error(result.error);
89+
}
90+
return result;
91+
},
8592
getPost: _discourse_get_post,
8693
getTopic: _discourse_get_topic,
8794
getUser: _discourse_get_user,
8895
getPersona: function(name) {
89-
return {
90-
respondTo: function(params) {
91-
result = _discourse_respond_to_persona(name, params);
96+
const personaDetails = _discourse_get_persona(name);
97+
if (personaDetails.error) {
98+
throw new Error(personaDetails.error);
99+
}
100+
101+
// merge result.persona with {}..
102+
return Object.assign({
103+
update: function(updates) {
104+
const result = _discourse_update_persona(name, updates);
92105
if (result.error) {
93106
throw new Error(result.error);
94107
}
95108
return result;
96109
},
97-
};
110+
respondTo: function(params) {
111+
const result = _discourse_respond_to_persona(name, params);
112+
if (result.error) {
113+
throw new Error(result.error);
114+
}
115+
return result;
116+
}
117+
}, personaDetails.persona);
98118
},
99119
createChatMessage: function(params) {
100120
const result = _discourse_create_chat_message(params);
@@ -160,6 +180,20 @@ def invoke
160180
{ error: "Script terminated due to timeout" }
161181
end
162182

183+
def has_custom_context?
184+
mini_racer_context.eval(tool.script)
185+
mini_racer_context.eval("typeof customContext === 'function'")
186+
rescue StandardError
187+
false
188+
end
189+
190+
def custom_context
191+
mini_racer_context.eval(tool.script)
192+
mini_racer_context.eval("customContext()")
193+
rescue StandardError
194+
nil
195+
end
196+
163197
private
164198

165199
MAX_FRAGMENTS = 200
@@ -443,6 +477,96 @@ def attach_discourse(mini_racer_context)
443477
end
444478
end,
445479
)
480+
481+
mini_racer_context.attach(
482+
"_discourse_get_persona",
483+
->(persona_name) do
484+
in_attached_function do
485+
persona = AiPersona.find_by(name: persona_name)
486+
487+
return { error: "Persona not found" } if persona.nil?
488+
489+
# Return a subset of relevant persona attributes
490+
{
491+
persona:
492+
persona.attributes.slice(
493+
"id",
494+
"name",
495+
"description",
496+
"enabled",
497+
"system_prompt",
498+
"temperature",
499+
"top_p",
500+
"vision_enabled",
501+
"tools",
502+
"max_context_posts",
503+
"allow_chat_channel_mentions",
504+
"allow_chat_direct_messages",
505+
"allow_topic_mentions",
506+
"allow_personal_messages",
507+
),
508+
}
509+
end
510+
end,
511+
)
512+
513+
mini_racer_context.attach(
514+
"_discourse_update_persona",
515+
->(persona_id_or_name, updates) do
516+
in_attached_function do
517+
# Find persona by ID or name
518+
persona = nil
519+
if persona_id_or_name.is_a?(Integer) ||
520+
persona_id_or_name.to_i.to_s == persona_id_or_name
521+
persona = AiPersona.find_by(id: persona_id_or_name.to_i)
522+
else
523+
persona = AiPersona.find_by(name: persona_id_or_name)
524+
end
525+
526+
return { error: "Persona not found" } if persona.nil?
527+
528+
allowed_updates = {}
529+
530+
if updates["system_prompt"].present?
531+
allowed_updates[:system_prompt] = updates["system_prompt"]
532+
end
533+
534+
if updates["temperature"].is_a?(Numeric)
535+
allowed_updates[:temperature] = updates["temperature"]
536+
end
537+
538+
allowed_updates[:top_p] = updates["top_p"] if updates["top_p"].is_a?(Numeric)
539+
540+
if updates["description"].present?
541+
allowed_updates[:description] = updates["description"]
542+
end
543+
544+
allowed_updates[:enabled] = updates["enabled"] if updates["enabled"].is_a?(
545+
TrueClass,
546+
) || updates["enabled"].is_a?(FalseClass)
547+
548+
if persona.update(allowed_updates)
549+
return(
550+
{
551+
success: true,
552+
persona:
553+
persona.attributes.slice(
554+
"id",
555+
"name",
556+
"description",
557+
"enabled",
558+
"system_prompt",
559+
"temperature",
560+
"top_p",
561+
),
562+
}
563+
)
564+
else
565+
return { error: persona.errors.full_messages.join(", ") }
566+
end
567+
end
568+
end,
569+
)
446570
end
447571

448572
def attach_upload(mini_racer_context)

lib/personas/tools/custom.rb

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,38 @@ def self.signature
2929
# Backwards compatibility: if tool_name is not set (existing custom tools), use name
3030
def self.name
3131
name, tool_name = AiTool.where(id: tool_id).pluck(:name, :tool_name).first
32-
3332
tool_name.presence || name
3433
end
3534

35+
def self.has_custom_context?
36+
# note on safety, this can be cached safely, we bump the whole persona cache when an ai tool is saved
37+
# which will expire this class
38+
return @has_custom_context if defined?(@has_custom_context)
39+
40+
@has_custom_context = false
41+
ai_tool = AiTool.find_by(id: tool_id)
42+
if ai_tool.script.include?("customContext")
43+
runner = ai_tool.runner({}, llm: nil, bot_user: nil, context: nil)
44+
@has_custom_context = runner.has_custom_context?
45+
end
46+
47+
@has_custom_context
48+
end
49+
50+
def self.inject_prompt(prompt:, context:, persona:)
51+
if has_custom_context?
52+
ai_tool = AiTool.find_by(id: tool_id)
53+
if ai_tool
54+
runner = ai_tool.runner({}, llm: nil, bot_user: nil, context: context)
55+
custom_context = runner.custom_context
56+
if custom_context.present?
57+
last_message = prompt.messages.last
58+
last_message[:content] = "#{custom_context}\n\n#{last_message[:content]}"
59+
end
60+
end
61+
end
62+
end
63+
3664
def initialize(*args, **kwargs)
3765
@chain_next_response = true
3866
super(*args, **kwargs)

plugin.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ def self.public_asset_path(name)
127127
end
128128
end
129129

130+
add_api_key_scope(
131+
:discourse_ai,
132+
{ update_personas: { actions: %w[discourse_ai/admin/ai_personas#update] } },
133+
)
134+
130135
plugin_icons = %w[
131136
chart-column
132137
spell-check

spec/lib/modules/ai_bot/playground_spec.rb

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,4 +1151,50 @@
11511151
expect(playground.available_bot_usernames).to include(persona.user.username)
11521152
end
11531153
end
1154+
1155+
describe "custom tool context injection" do
1156+
let!(:custom_tool) do
1157+
AiTool.create!(
1158+
name: "context_tool",
1159+
tool_name: "context_tool",
1160+
summary: "tool with custom context",
1161+
description: "A test custom tool that injects context",
1162+
parameters: [{ name: "query", type: "string", description: "Input for the custom tool" }],
1163+
script: <<~JS,
1164+
function invoke(params) {
1165+
return 'Custom tool result: ' + params.query;
1166+
}
1167+
1168+
function customContext() {
1169+
return "This is additional context from the tool";
1170+
}
1171+
1172+
function details() {
1173+
return 'executed with custom context';
1174+
}
1175+
JS
1176+
created_by: user,
1177+
)
1178+
end
1179+
1180+
let!(:ai_persona) { Fabricate(:ai_persona, tools: ["custom-#{custom_tool.id}"]) }
1181+
let(:bot) { DiscourseAi::Personas::Bot.as(bot_user, persona: ai_persona.class_instance.new) }
1182+
let(:playground) { DiscourseAi::AiBot::Playground.new(bot) }
1183+
1184+
it "injects custom context into the prompt" do
1185+
prompts = nil
1186+
response = "I received the additional context"
1187+
1188+
DiscourseAi::Completions::Llm.with_prepared_responses([response]) do |_, _, _prompts|
1189+
new_post = Fabricate(:post, raw: "Can you use the custom context tool?")
1190+
playground.reply_to(new_post)
1191+
prompts = _prompts
1192+
end
1193+
1194+
# The first prompt should have the custom context prepended to the user message
1195+
user_message = prompts[0].messages.last
1196+
expect(user_message[:content]).to include("This is additional context from the tool")
1197+
expect(user_message[:content]).to include("Can you use the custom context tool?")
1198+
end
1199+
end
11541200
end

spec/models/ai_tool_spec.rb

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,4 +560,114 @@ def stub_embeddings
560560
expect(Chat::Message.count).to eq(initial_message_count) # Verify no message created
561561
end
562562
end
563+
564+
context "when updating personas" do
565+
fab!(:ai_persona) do
566+
Fabricate(:ai_persona, name: "TestPersona", system_prompt: "Original prompt")
567+
end
568+
569+
it "can update a persona with proper permissions" do
570+
script = <<~JS
571+
function invoke(params) {
572+
return discourse.updatePersona(params.persona_name, {
573+
system_prompt: params.new_prompt,
574+
temperature: 0.7,
575+
top_p: 0.9
576+
});
577+
}
578+
JS
579+
580+
tool = create_tool(script: script)
581+
runner =
582+
tool.runner(
583+
{ persona_name: "TestPersona", new_prompt: "Updated system prompt" },
584+
llm: nil,
585+
bot_user: bot_user,
586+
)
587+
588+
result = runner.invoke
589+
expect(result["success"]).to eq(true)
590+
expect(result["persona"]["system_prompt"]).to eq("Updated system prompt")
591+
expect(result["persona"]["temperature"]).to eq(0.7)
592+
593+
ai_persona.reload
594+
expect(ai_persona.system_prompt).to eq("Updated system prompt")
595+
expect(ai_persona.temperature).to eq(0.7)
596+
expect(ai_persona.top_p).to eq(0.9)
597+
end
598+
end
599+
600+
context "when fetching persona information" do
601+
fab!(:ai_persona) do
602+
Fabricate(
603+
:ai_persona,
604+
name: "TestPersona",
605+
description: "Test description",
606+
system_prompt: "Test system prompt",
607+
temperature: 0.8,
608+
top_p: 0.9,
609+
vision_enabled: true,
610+
tools: ["Search", ["WebSearch", { param: "value" }, true]],
611+
)
612+
end
613+
614+
it "can fetch a persona by name" do
615+
script = <<~JS
616+
function invoke(params) {
617+
const persona = discourse.getPersona(params.persona_name);
618+
return persona;
619+
}
620+
JS
621+
622+
tool = create_tool(script: script)
623+
runner = tool.runner({ persona_name: "TestPersona" }, llm: nil, bot_user: bot_user)
624+
625+
result = runner.invoke
626+
627+
expect(result["id"]).to eq(ai_persona.id)
628+
expect(result["name"]).to eq("TestPersona")
629+
expect(result["description"]).to eq("Test description")
630+
expect(result["system_prompt"]).to eq("Test system prompt")
631+
expect(result["temperature"]).to eq(0.8)
632+
expect(result["top_p"]).to eq(0.9)
633+
expect(result["vision_enabled"]).to eq(true)
634+
expect(result["tools"]).to include("Search")
635+
expect(result["tools"][1]).to be_a(Array)
636+
end
637+
638+
it "raises an error when the persona doesn't exist" do
639+
script = <<~JS
640+
function invoke(params) {
641+
return discourse.getPersona("NonExistentPersona");
642+
}
643+
JS
644+
645+
tool = create_tool(script: script)
646+
runner = tool.runner({}, llm: nil, bot_user: bot_user)
647+
648+
expect { runner.invoke }.to raise_error(MiniRacer::RuntimeError, /Persona not found/)
649+
end
650+
651+
it "can update a persona after fetching it" do
652+
script = <<~JS
653+
function invoke(params) {
654+
const persona = discourse.getPersona("TestPersona");
655+
return persona.update({
656+
system_prompt: "Updated through getPersona().update()",
657+
temperature: 0.5
658+
});
659+
}
660+
JS
661+
662+
tool = create_tool(script: script)
663+
runner = tool.runner({}, llm: nil, bot_user: bot_user)
664+
665+
result = runner.invoke
666+
expect(result["success"]).to eq(true)
667+
668+
ai_persona.reload
669+
expect(ai_persona.system_prompt).to eq("Updated through getPersona().update()")
670+
expect(ai_persona.temperature).to eq(0.5)
671+
end
672+
end
563673
end

0 commit comments

Comments
 (0)