mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-06-19 05:35:40 +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>
1912 lines
65 KiB
Ruby
Vendored
1912 lines
65 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
RSpec.describe DiscourseAi::Admin::AiAgentsController do
|
|
fab!(:admin)
|
|
fab!(:admin_2, :admin)
|
|
fab!(:ai_agent)
|
|
fab!(:embedding_definition)
|
|
fab!(:llm_model)
|
|
|
|
before do
|
|
enable_current_plugin
|
|
sign_in(admin)
|
|
SiteSetting.ai_embeddings_selected_model = embedding_definition.id
|
|
SiteSetting.ai_embeddings_enabled = true
|
|
end
|
|
|
|
describe "GET #index" do
|
|
it "returns a success response" do
|
|
get "/admin/plugins/discourse-ai/ai-agents.json"
|
|
expect(response).to be_successful
|
|
|
|
expect(response.parsed_body["ai_agents"].length).to eq(AiAgent.count)
|
|
|
|
expected_tool_count =
|
|
DiscourseAi::Agents::Agent.all_available_tools.length +
|
|
DiscourseAi::Agents::Agent.external_tools.length +
|
|
DiscourseAi::Completions::NativeTools.all.length
|
|
expect(response.parsed_body["meta"]["tools"].length).to eq(expected_tool_count)
|
|
end
|
|
|
|
it "includes provider-native tools in the tools list" do
|
|
get "/admin/plugins/discourse-ai/ai-agents.json"
|
|
tools = response.parsed_body["meta"]["tools"]
|
|
native_tool = tools.find { |t| t["id"] == "native-web_search" }
|
|
native_fetch_tool = tools.find { |t| t["id"] == "native-web_fetch" }
|
|
expect(native_tool).to be_present
|
|
expect(native_tool["native"]).to eq(true)
|
|
expect(native_fetch_tool).to be_present
|
|
expect(native_fetch_tool["native"]).to eq(true)
|
|
end
|
|
|
|
it "includes external plugin tools in the tools list" do
|
|
get "/admin/plugins/discourse-ai/ai-agents.json"
|
|
tools = response.parsed_body["meta"]["tools"]
|
|
tool_ids = tools.map { |t| t["id"] }
|
|
expect(tool_ids).to include("RunSql")
|
|
end
|
|
|
|
it "includes token_count for custom tools" do
|
|
tool =
|
|
AiTool.create!(
|
|
name: "Token Test",
|
|
tool_name: "token_test",
|
|
description: "A test tool",
|
|
parameters: [{ name: "query", type: "string", description: "search query" }],
|
|
script: "function invoke(params) { return 'test'; }",
|
|
summary: "Test",
|
|
created_by_id: admin.id,
|
|
enabled: true,
|
|
)
|
|
|
|
get "/admin/plugins/discourse-ai/ai-agents.json"
|
|
expect(response).to be_successful
|
|
|
|
tools = response.parsed_body["meta"]["tools"]
|
|
custom_tool = tools.find { |t| t["id"] == "custom-#{tool.id}" }
|
|
expect(custom_tool["token_count"]).to be_a(Integer)
|
|
expect(custom_tool["token_count"]).to be > 0
|
|
end
|
|
|
|
it "sideloads llms" do
|
|
get "/admin/plugins/discourse-ai/ai-agents.json"
|
|
expect(response).to be_successful
|
|
|
|
expect(response.parsed_body["meta"]["llms"]).to eq(
|
|
[
|
|
{
|
|
id: llm_model.id,
|
|
name: llm_model.display_name,
|
|
vision_enabled: llm_model.vision_enabled,
|
|
supported_native_tools: [],
|
|
}.stringify_keys,
|
|
],
|
|
)
|
|
end
|
|
|
|
it "returns tool options with each tool" do
|
|
agent1 = Fabricate(:ai_agent, name: "search1", tools: ["SearchCommand"])
|
|
agent2 =
|
|
Fabricate(
|
|
:ai_agent,
|
|
name: "search2",
|
|
tools: [["SearchCommand", { base_query: "test" }, true]],
|
|
allow_topic_mentions: true,
|
|
allow_personal_messages: true,
|
|
allow_chat_channel_mentions: true,
|
|
allow_chat_direct_messages: true,
|
|
default_llm_id: llm_model.id,
|
|
forced_tool_count: 2,
|
|
)
|
|
agent2.create_user!
|
|
|
|
get "/admin/plugins/discourse-ai/ai-agents.json"
|
|
expect(response).to be_successful
|
|
|
|
serializer_agent1 = response.parsed_body["ai_agents"].find { |p| p["id"] == agent1.id }
|
|
serializer_agent2 = response.parsed_body["ai_agents"].find { |p| p["id"] == agent2.id }
|
|
|
|
expect(serializer_agent2["allow_topic_mentions"]).to eq(true)
|
|
expect(serializer_agent2["allow_personal_messages"]).to eq(true)
|
|
expect(serializer_agent2["allow_chat_channel_mentions"]).to eq(true)
|
|
expect(serializer_agent2["allow_chat_direct_messages"]).to eq(true)
|
|
|
|
expect(serializer_agent2["default_llm_id"]).to eq(llm_model.id)
|
|
expect(serializer_agent2).not_to have_key("question_consolidator_llm_id")
|
|
expect(serializer_agent2["user_id"]).to eq(agent2.user_id)
|
|
expect(serializer_agent2["user"]["id"]).to eq(agent2.user_id)
|
|
expect(serializer_agent2["forced_tool_count"]).to eq(2)
|
|
|
|
tools = response.parsed_body["meta"]["tools"]
|
|
search_tool = tools.find { |c| c["id"] == "Search" }
|
|
|
|
expect(search_tool["help"]).to eq(I18n.t("discourse_ai.ai_bot.tool_help.search"))
|
|
expect(search_tool["token_count"]).to be_a(Integer)
|
|
expect(search_tool["token_count"]).to be > 0
|
|
|
|
expect(search_tool["options"]).to eq(
|
|
{
|
|
"base_query" => {
|
|
"type" => "string",
|
|
"name" => I18n.t("discourse_ai.ai_bot.tool_options.search.base_query.name"),
|
|
"description" =>
|
|
I18n.t("discourse_ai.ai_bot.tool_options.search.base_query.description"),
|
|
},
|
|
"max_results" => {
|
|
"type" => "integer",
|
|
"name" => I18n.t("discourse_ai.ai_bot.tool_options.search.max_results.name"),
|
|
"description" =>
|
|
I18n.t("discourse_ai.ai_bot.tool_options.search.max_results.description"),
|
|
},
|
|
"search_private" => {
|
|
"type" => "boolean",
|
|
"name" => I18n.t("discourse_ai.ai_bot.tool_options.search.search_private.name"),
|
|
"description" =>
|
|
I18n.t("discourse_ai.ai_bot.tool_options.search.search_private.description"),
|
|
},
|
|
},
|
|
)
|
|
|
|
expect(serializer_agent1["tools"]).to eq(["SearchCommand"])
|
|
expect(serializer_agent2["tools"]).to eq(
|
|
[["SearchCommand", { "base_query" => "test" }, true]],
|
|
)
|
|
end
|
|
|
|
it "includes configured mcp servers in meta" do
|
|
Fabricate(:ai_mcp_server, name: "Jira")
|
|
DiscourseAi::Mcp::ToolRegistry.stubs(:tool_definitions_for).returns(
|
|
[{ "name" => "search_issues", "description" => "Search issues", "inputSchema" => {} }],
|
|
)
|
|
|
|
get "/admin/plugins/discourse-ai/ai-agents.json"
|
|
|
|
expect(response).to be_successful
|
|
expect(response.parsed_body["meta"]["mcp_servers"]).to include(
|
|
a_hash_including(
|
|
"name" => "Jira",
|
|
"tool_count" => 1,
|
|
"token_count" => an_instance_of(Integer),
|
|
"tools" => [a_hash_including("name" => "search_issues")],
|
|
),
|
|
)
|
|
end
|
|
|
|
it "includes selected MCP tool metadata in the serialized agent" do
|
|
server = Fabricate(:ai_mcp_server, name: "Jira")
|
|
ai_agent.ai_mcp_servers << server
|
|
ai_agent
|
|
.ai_agent_mcp_servers
|
|
.find_by!(ai_mcp_server_id: server.id)
|
|
.update!(selected_tool_names: ["search_issues"])
|
|
DiscourseAi::Mcp::ToolRegistry.stubs(:tool_definitions_for).returns(
|
|
[{ "name" => "search_issues", "description" => "Search issues", "inputSchema" => {} }],
|
|
)
|
|
|
|
get "/admin/plugins/discourse-ai/ai-agents/#{ai_agent.id}/edit.json"
|
|
|
|
expect(response).to be_successful
|
|
expect(response.parsed_body.dig("ai_agent", "mcp_servers")).to include(
|
|
a_hash_including(
|
|
"name" => "Jira",
|
|
"tool_count" => 1,
|
|
"token_count" => an_instance_of(Integer),
|
|
"selected_tool_names" => ["search_issues"],
|
|
"selected_tool_count" => 1,
|
|
"selected_token_count" => an_instance_of(Integer),
|
|
),
|
|
)
|
|
expect(response.parsed_body.dig("ai_agent", "mcp_server_tool_names")).to eq(
|
|
{ server.id.to_s => ["search_issues"] },
|
|
)
|
|
end
|
|
|
|
context "with translations" do
|
|
before do
|
|
SiteSetting.default_locale = "fr"
|
|
|
|
TranslationOverride.upsert!(
|
|
SiteSetting.default_locale,
|
|
"discourse_ai.ai_bot.agents.general.name",
|
|
"Général",
|
|
)
|
|
TranslationOverride.upsert!(
|
|
SiteSetting.default_locale,
|
|
"discourse_ai.ai_bot.agents.general.description",
|
|
"Général Description",
|
|
)
|
|
end
|
|
|
|
it "returns localized agent names and descriptions" do
|
|
get "/admin/plugins/discourse-ai/ai-agents.json"
|
|
|
|
id = DiscourseAi::Agents::Agent.system_agents[DiscourseAi::Agents::General]
|
|
agent = response.parsed_body["ai_agents"].find { |p| p["id"] == id }
|
|
|
|
expect(agent["name"]).to eq("Général")
|
|
expect(agent["description"]).to eq("Général Description")
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "GET #edit" do
|
|
it "returns a success response" do
|
|
get "/admin/plugins/discourse-ai/ai-agents/#{ai_agent.id}/edit.json"
|
|
expect(response).to be_successful
|
|
expect(response.parsed_body["ai_agent"]["name"]).to eq(ai_agent.name)
|
|
end
|
|
|
|
it "supports ai-agents edit endpoint and payload root" do
|
|
get "/admin/plugins/discourse-ai/ai-agents/#{ai_agent.id}/edit.json"
|
|
expect(response).to be_successful
|
|
expect(response.parsed_body["ai_agent"]["name"]).to eq(ai_agent.name)
|
|
end
|
|
|
|
it "includes rag uploads for each agent" do
|
|
upload = Fabricate(:upload)
|
|
RagDocumentFragment.link_target_and_uploads(ai_agent, [upload.id])
|
|
|
|
get "/admin/plugins/discourse-ai/ai-agents/#{ai_agent.id}/edit.json"
|
|
expect(response).to be_successful
|
|
|
|
serialized_agent = response.parsed_body["ai_agent"]
|
|
|
|
expect(serialized_agent.dig("rag_uploads", 0, "id")).to eq(upload.id)
|
|
expect(serialized_agent.dig("rag_uploads", 0, "original_filename")).to eq(
|
|
upload.original_filename,
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "POST #create" do
|
|
context "with valid params" do
|
|
let(:valid_attributes) do
|
|
ai_mcp_server = Fabricate(:ai_mcp_server, name: "Jira")
|
|
DiscourseAi::Mcp::ToolRegistry.stubs(:tool_definitions_for).returns(
|
|
[{ "name" => "search_issues", "description" => "Search issues" }],
|
|
)
|
|
|
|
{
|
|
name: "superbot",
|
|
description: "Assists with tasks",
|
|
system_prompt: "you are a helpful bot",
|
|
tools: [["search", { "base_query" => "test" }, true]],
|
|
top_p: 0.1,
|
|
temperature: 0.5,
|
|
allow_topic_mentions: true,
|
|
allow_personal_messages: true,
|
|
allow_chat_channel_mentions: true,
|
|
allow_chat_direct_messages: true,
|
|
default_llm_id: llm_model.id,
|
|
forced_tool_count: 2,
|
|
mcp_server_ids: [ai_mcp_server.id],
|
|
mcp_server_tool_names: {
|
|
ai_mcp_server.id.to_s => ["search_issues"],
|
|
},
|
|
execution_mode: "agentic",
|
|
max_turn_tokens: 5000,
|
|
compression_threshold: 80,
|
|
response_format: [{ key: "summary", type: "string" }],
|
|
examples: [%w[user_msg1 assistant_msg1], %w[user_msg2 assistant_msg2]],
|
|
}
|
|
end
|
|
|
|
it "creates a new AiAgent" do
|
|
expect {
|
|
post "/admin/plugins/discourse-ai/ai-agents.json",
|
|
params: { ai_agent: valid_attributes }.to_json,
|
|
headers: {
|
|
"CONTENT_TYPE" => "application/json",
|
|
}
|
|
|
|
expect(response).to be_successful
|
|
agent_json = response.parsed_body["ai_agent"]
|
|
|
|
expect(agent_json["name"]).to eq("superbot")
|
|
expect(agent_json["top_p"]).to eq(0.1)
|
|
expect(agent_json["temperature"]).to eq(0.5)
|
|
expect(agent_json["default_llm_id"]).to eq(llm_model.id)
|
|
expect(agent_json["forced_tool_count"]).to eq(2)
|
|
expect(agent_json["execution_mode"]).to eq("agentic")
|
|
expect(agent_json["max_turn_tokens"]).to eq(5000)
|
|
|
|
expect(agent_json["allow_topic_mentions"]).to eq(true)
|
|
expect(agent_json["allow_personal_messages"]).to eq(true)
|
|
expect(agent_json["allow_chat_channel_mentions"]).to eq(true)
|
|
expect(agent_json["allow_chat_direct_messages"]).to eq(true)
|
|
expect(agent_json).not_to have_key("question_consolidator_llm_id")
|
|
expect(agent_json["response_format"].map { |rf| rf["key"] }).to contain_exactly("summary")
|
|
expect(agent_json["examples"]).to eq(valid_attributes[:examples])
|
|
|
|
agent = AiAgent.find(agent_json["id"])
|
|
|
|
expect(agent.tools).to eq([["search", { "base_query" => "test" }, true]])
|
|
expect(agent.user).to be_present
|
|
expect(agent_json["user_id"]).to eq(agent.user_id)
|
|
expect(agent_json["user"]["id"]).to eq(agent.user_id)
|
|
expect(agent.ai_mcp_servers.pluck(:name)).to eq(["Jira"])
|
|
expect(agent.ai_agent_mcp_servers.first.selected_tool_names).to eq(["search_issues"])
|
|
expect(agent.top_p).to eq(0.1)
|
|
expect(agent.temperature).to eq(0.5)
|
|
}.to change(AiAgent, :count).by(1)
|
|
end
|
|
|
|
it "creates with ai_agent payload on ai-agents endpoint" do
|
|
expect {
|
|
post "/admin/plugins/discourse-ai/ai-agents.json",
|
|
params: { ai_agent: valid_attributes }.to_json,
|
|
headers: {
|
|
"CONTENT_TYPE" => "application/json",
|
|
}
|
|
|
|
expect(response).to be_successful
|
|
expect(response.parsed_body.dig("ai_agent", "name")).to eq("superbot")
|
|
}.to change(AiAgent, :count).by(1)
|
|
end
|
|
|
|
it "logs staff action when creating a agent" do
|
|
# Create the agent
|
|
post "/admin/plugins/discourse-ai/ai-agents.json",
|
|
params: { ai_agent: valid_attributes }.to_json,
|
|
headers: {
|
|
"CONTENT_TYPE" => "application/json",
|
|
}
|
|
|
|
expect(response).to be_successful
|
|
|
|
# Now verify the log was created with the right subject
|
|
history =
|
|
UserHistory.where(
|
|
action: UserHistory.actions[:custom_staff],
|
|
custom_type: "create_ai_agent",
|
|
).last
|
|
expect(history).to be_present
|
|
expect(history.subject).to eq("superbot") # Verify subject is set to name
|
|
end
|
|
end
|
|
|
|
context "with invalid params" do
|
|
it "renders a JSON response with errors for the new ai_agent" do
|
|
post "/admin/plugins/discourse-ai/ai-agents.json", params: { ai_agent: { foo: "" } } # invalid attribute
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.content_type).to include("application/json")
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "POST #create_user" do
|
|
it "creates a user for the agent" do
|
|
post "/admin/plugins/discourse-ai/ai-agents/#{ai_agent.id}/create-user.json"
|
|
ai_agent.reload
|
|
|
|
expect(response).to be_successful
|
|
expect(response.parsed_body["user"]["id"]).to eq(ai_agent.user_id)
|
|
end
|
|
end
|
|
|
|
describe "PUT #update" do
|
|
context "with scoped api key" do
|
|
it "allows updates with a properly scoped API key" do
|
|
api_key = Fabricate(:api_key, user: admin, created_by: admin)
|
|
|
|
scope =
|
|
ApiKeyScope.create!(
|
|
resource: "ai",
|
|
action: "update_agents",
|
|
api_key_id: api_key.id,
|
|
allowed_parameters: {
|
|
},
|
|
)
|
|
|
|
put "/admin/plugins/discourse-ai/ai-agents/#{ai_agent.id}.json",
|
|
params: {
|
|
ai_agent: {
|
|
name: "UpdatedByAPI",
|
|
description: "Updated via API key",
|
|
},
|
|
},
|
|
headers: {
|
|
"Api-Key" => api_key.key,
|
|
"Api-Username" => admin.username,
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
ai_agent.reload
|
|
expect(ai_agent.name).to eq("UpdatedByAPI")
|
|
expect(ai_agent.description).to eq("Updated via API key")
|
|
|
|
scope.update!(action: "fake")
|
|
|
|
put "/admin/plugins/discourse-ai/ai-agents/#{ai_agent.id}.json",
|
|
params: {
|
|
ai_agent: {
|
|
name: "UpdatedByAPI 2",
|
|
description: "Updated via API key",
|
|
},
|
|
},
|
|
headers: {
|
|
"Api-Key" => api_key.key,
|
|
"Api-Username" => admin.username,
|
|
}
|
|
|
|
expect(response).not_to have_http_status(:ok)
|
|
end
|
|
end
|
|
|
|
it "creates a missing bot user when forcing the default LLM" do
|
|
agent = Fabricate(:ai_agent, name: "test_bot2", user_id: nil, default_llm_id: llm_model.id)
|
|
|
|
put "/admin/plugins/discourse-ai/ai-agents/#{agent.id}.json",
|
|
params: {
|
|
ai_agent: {
|
|
force_default_llm: true,
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
agent.reload
|
|
expect(agent.user).to be_present
|
|
expect(response.parsed_body.dig("ai_agent", "user", "id")).to eq(agent.user_id)
|
|
end
|
|
|
|
it "creates a missing bot user when enabling mention/chat entry points" do
|
|
%i[
|
|
allow_topic_mentions
|
|
allow_chat_direct_messages
|
|
allow_chat_channel_mentions
|
|
].each do |field|
|
|
agent =
|
|
Fabricate(:ai_agent, name: "#{field}_bot", user_id: nil, default_llm_id: llm_model.id)
|
|
|
|
put "/admin/plugins/discourse-ai/ai-agents/#{agent.id}.json",
|
|
params: {
|
|
ai_agent: {
|
|
field => true,
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
agent.reload
|
|
expect(agent.user).to be_present
|
|
expect(response.parsed_body.dig("ai_agent", "user", "id")).to eq(agent.user_id)
|
|
end
|
|
end
|
|
|
|
it "does not create a missing bot user for personal messages alone" do
|
|
agent =
|
|
Fabricate(
|
|
:ai_agent,
|
|
name: "personal_only_bot",
|
|
user_id: nil,
|
|
allow_personal_messages: false,
|
|
)
|
|
|
|
put "/admin/plugins/discourse-ai/ai-agents/#{agent.id}.json",
|
|
params: {
|
|
ai_agent: {
|
|
allow_personal_messages: true,
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
expect(agent.reload.user).to be_nil
|
|
end
|
|
|
|
it "allows us to trivially clear top_p and temperature" do
|
|
agent = Fabricate(:ai_agent, name: "test_bot2", top_p: 0.5, temperature: 0.1)
|
|
put "/admin/plugins/discourse-ai/ai-agents/#{agent.id}.json",
|
|
params: {
|
|
ai_agent: {
|
|
top_p: "",
|
|
temperature: "",
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
agent.reload
|
|
|
|
expect(agent.top_p).to eq(nil)
|
|
expect(agent.temperature).to eq(nil)
|
|
end
|
|
|
|
it "logs staff action when updating a agent" do
|
|
agent = Fabricate(:ai_agent, name: "original_name", description: "original description")
|
|
|
|
# Update the agent
|
|
put "/admin/plugins/discourse-ai/ai-agents/#{agent.id}.json",
|
|
params: {
|
|
ai_agent: {
|
|
name: "updated_name",
|
|
description: "updated description",
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
agent.reload
|
|
expect(agent.name).to eq("updated_name")
|
|
expect(agent.description).to eq("updated description")
|
|
|
|
# Now verify the log was created with the right subject
|
|
history =
|
|
UserHistory.where(
|
|
action: UserHistory.actions[:custom_staff],
|
|
custom_type: "update_ai_agent",
|
|
).last
|
|
expect(history).to be_present
|
|
expect(history.subject).to eq("updated_name") # Verify subject is set to the new name
|
|
end
|
|
|
|
it "supports updating rag params" do
|
|
agent = Fabricate(:ai_agent, name: "test_bot2")
|
|
|
|
put "/admin/plugins/discourse-ai/ai-agents/#{agent.id}.json",
|
|
params: {
|
|
ai_agent: {
|
|
rag_chunk_tokens: "102",
|
|
rag_chunk_overlap_tokens: "12",
|
|
rag_conversation_chunks: "13",
|
|
rag_llm_model_id: llm_model.id,
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
agent.reload
|
|
|
|
expect(agent.rag_chunk_tokens).to eq(102)
|
|
expect(agent.rag_chunk_overlap_tokens).to eq(12)
|
|
expect(agent.rag_conversation_chunks).to eq(13)
|
|
expect(agent.rag_llm_model_id).to eq(llm_model.id)
|
|
end
|
|
|
|
it "supports updating agentic params" do
|
|
agent = Fabricate(:ai_agent, name: "test_bot2")
|
|
|
|
put "/admin/plugins/discourse-ai/ai-agents/#{agent.id}.json",
|
|
params: {
|
|
ai_agent: {
|
|
execution_mode: "agentic",
|
|
max_turn_tokens: 8000,
|
|
compression_threshold: 75,
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
agent.reload
|
|
|
|
expect(agent.execution_mode).to eq("agentic")
|
|
expect(agent.max_turn_tokens).to eq(8000)
|
|
|
|
expect(agent.compression_threshold).to eq(75)
|
|
end
|
|
|
|
it "supports updating selected MCP tool names" do
|
|
server = Fabricate(:ai_mcp_server, name: "Jira")
|
|
agent = Fabricate(:ai_agent, name: "test_bot2")
|
|
agent.ai_mcp_servers << server
|
|
DiscourseAi::Mcp::ToolRegistry
|
|
.stubs(:tool_definitions_for)
|
|
.with(server)
|
|
.returns(
|
|
[{ "name" => "search_issues", "description" => "Search issues", "inputSchema" => {} }],
|
|
)
|
|
|
|
put "/admin/plugins/discourse-ai/ai-agents/#{agent.id}.json",
|
|
params: {
|
|
ai_agent: {
|
|
mcp_server_ids: [server.id],
|
|
mcp_server_tool_names: {
|
|
server.id.to_s => ["search_issues"],
|
|
},
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
expect(agent.reload.ai_agent_mcp_servers.first.selected_tool_names).to eq(["search_issues"])
|
|
end
|
|
|
|
it "preserves hidden disabled MCP assignments when updating visible ones" do
|
|
enabled_server = Fabricate(:ai_mcp_server, name: "Jira")
|
|
disabled_server = Fabricate(:ai_mcp_server, name: "Legacy Docs", enabled: false)
|
|
agent = Fabricate(:ai_agent, name: "test_bot2")
|
|
agent.ai_mcp_servers << enabled_server
|
|
agent.ai_mcp_servers << disabled_server
|
|
DiscourseAi::Mcp::ToolRegistry
|
|
.stubs(:tool_definitions_for)
|
|
.with(enabled_server)
|
|
.returns(
|
|
[{ "name" => "search_issues", "description" => "Search issues", "inputSchema" => {} }],
|
|
)
|
|
agent
|
|
.ai_agent_mcp_servers
|
|
.find_by!(ai_mcp_server_id: disabled_server.id)
|
|
.update!(selected_tool_names: ["search_legacy"])
|
|
|
|
put "/admin/plugins/discourse-ai/ai-agents/#{agent.id}.json",
|
|
params: {
|
|
ai_agent: {
|
|
name: "updated",
|
|
mcp_server_ids: [enabled_server.id],
|
|
mcp_server_tool_names: {
|
|
enabled_server.id.to_s => ["search_issues"],
|
|
},
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
|
|
agent.reload
|
|
expect(agent.ai_mcp_servers.pluck(:id)).to contain_exactly(
|
|
enabled_server.id,
|
|
disabled_server.id,
|
|
)
|
|
expect(
|
|
agent
|
|
.ai_agent_mcp_servers
|
|
.find_by!(ai_mcp_server_id: disabled_server.id)
|
|
.selected_tool_names,
|
|
).to eq(["search_legacy"])
|
|
end
|
|
|
|
it "supports updating vision params" do
|
|
agent = Fabricate(:ai_agent, name: "test_bot2")
|
|
put "/admin/plugins/discourse-ai/ai-agents/#{agent.id}.json",
|
|
params: {
|
|
ai_agent: {
|
|
vision_enabled: true,
|
|
vision_max_pixels: 512 * 512,
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
agent.reload
|
|
|
|
expect(agent.vision_enabled).to eq(true)
|
|
expect(agent.vision_max_pixels).to eq(512 * 512)
|
|
end
|
|
|
|
it "does not allow temperature and top p changes on stock agents" do
|
|
put "/admin/plugins/discourse-ai/ai-agents/#{DiscourseAi::Agents::Agent.system_agents.values.first}.json",
|
|
params: {
|
|
ai_agent: {
|
|
top_p: 0.5,
|
|
temperature: 0.1,
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
end
|
|
|
|
context "with valid params" do
|
|
it "updates the requested ai_agent" do
|
|
put "/admin/plugins/discourse-ai/ai-agents/#{ai_agent.id}.json",
|
|
params: {
|
|
ai_agent: {
|
|
name: "SuperBot",
|
|
enabled: false,
|
|
tools: ["search"],
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
expect(response.content_type).to include("application/json")
|
|
|
|
ai_agent.reload
|
|
expect(ai_agent.name).to eq("SuperBot")
|
|
expect(ai_agent.enabled).to eq(false)
|
|
expect(ai_agent.tools).to eq([["search", nil, false]])
|
|
end
|
|
end
|
|
|
|
context "with system agents" do
|
|
it "does not allow editing of system prompts" do
|
|
put "/admin/plugins/discourse-ai/ai-agents/#{DiscourseAi::Agents::Agent.system_agents.values.first}.json",
|
|
params: {
|
|
ai_agent: {
|
|
system_prompt: "you are not a helpful bot",
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body["errors"].join).not_to be_blank
|
|
expect(response.parsed_body["errors"].join).not_to include("en.discourse")
|
|
end
|
|
|
|
it "does not allow editing of tools" do
|
|
put "/admin/plugins/discourse-ai/ai-agents/#{DiscourseAi::Agents::Agent.system_agents.values.first}.json",
|
|
params: {
|
|
ai_agent: {
|
|
tools: %w[SearchCommand ImageCommand],
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body["errors"].join).not_to be_blank
|
|
expect(response.parsed_body["errors"].join).not_to include("en.discourse")
|
|
end
|
|
|
|
it "does not allow editing of name and description cause it is localized" do
|
|
put "/admin/plugins/discourse-ai/ai-agents/#{DiscourseAi::Agents::Agent.system_agents.values.first}.json",
|
|
params: {
|
|
ai_agent: {
|
|
name: "bob",
|
|
description: "the bob",
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body["errors"].join).not_to be_blank
|
|
expect(response.parsed_body["errors"].join).not_to include("en.discourse")
|
|
end
|
|
|
|
it "does allow some actions" do
|
|
put "/admin/plugins/discourse-ai/ai-agents/#{DiscourseAi::Agents::Agent.system_agents.values.first}.json",
|
|
params: {
|
|
ai_agent: {
|
|
allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_1]],
|
|
enabled: false,
|
|
priority: 989,
|
|
},
|
|
}
|
|
|
|
expect(response).to be_successful
|
|
end
|
|
end
|
|
|
|
context "with invalid params" do
|
|
it "renders a JSON response with errors for the ai_agent" do
|
|
put "/admin/plugins/discourse-ai/ai-agents/#{ai_agent.id}.json",
|
|
params: {
|
|
ai_agent: {
|
|
name: "",
|
|
},
|
|
} # invalid attribute
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.content_type).to include("application/json")
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "GET #export" do
|
|
fab!(:ai_tool) do
|
|
AiTool.create!(
|
|
name: "Test Tool",
|
|
tool_name: "test_tool",
|
|
description: "A test tool",
|
|
script: "function invoke(params) { return 'test'; }",
|
|
parameters: [{ name: "query", type: "string", required: true }],
|
|
summary: "Test tool summary",
|
|
created_by_id: admin.id,
|
|
)
|
|
end
|
|
|
|
fab!(:agent_with_tools) do
|
|
AiAgent.create!(
|
|
name: "ToolMaster",
|
|
description: "A agent with custom tools",
|
|
system_prompt: "You are a tool master",
|
|
tools: [
|
|
["SearchCommand", { "base_query" => "test" }, true],
|
|
["custom-#{ai_tool.id}", { "max_results" => 10 }, false],
|
|
],
|
|
temperature: 0.8,
|
|
top_p: 0.9,
|
|
response_format: [{ type: "string", key: "summary" }],
|
|
examples: [["user example", "assistant example"]],
|
|
default_llm_id: llm_model.id,
|
|
)
|
|
end
|
|
|
|
it "exports a agent as JSON" do
|
|
get "/admin/plugins/discourse-ai/ai-agents/#{agent_with_tools.id}/export.json"
|
|
|
|
expect(response).to be_successful
|
|
expect(response.headers["Content-Disposition"]).to include("attachment")
|
|
expect(response.headers["Content-Disposition"]).to include("toolmaster.json")
|
|
|
|
json = response.parsed_body
|
|
expect(json["meta"]["version"]).to eq("1.0")
|
|
expect(json["meta"]["exported_at"]).to be_present
|
|
|
|
agent_data = json["agent"]
|
|
expect(agent_data["name"]).to eq("ToolMaster")
|
|
expect(agent_data["description"]).to eq("A agent with custom tools")
|
|
expect(agent_data["system_prompt"]).to eq("You are a tool master")
|
|
expect(agent_data["temperature"]).to eq(0.8)
|
|
expect(agent_data["top_p"]).to eq(0.9)
|
|
expect(agent_data["response_format"]).to eq([{ "type" => "string", "key" => "summary" }])
|
|
expect(agent_data["examples"]).to eq([["user example", "assistant example"]])
|
|
|
|
# Check that custom tool ID is replaced with tool_name
|
|
expect(agent_data["tools"]).to include(
|
|
["SearchCommand", { "base_query" => "test" }, true],
|
|
["custom-test_tool", { "max_results" => 10 }, false],
|
|
)
|
|
|
|
# Check custom tools are exported
|
|
expect(json["custom_tools"]).to be_an(Array)
|
|
expect(json["custom_tools"].length).to eq(1)
|
|
|
|
custom_tool = json["custom_tools"].first
|
|
expect(custom_tool["identifier"]).to eq("test_tool")
|
|
expect(custom_tool["name"]).to eq("Test Tool")
|
|
expect(custom_tool["description"]).to eq("A test tool")
|
|
expect(custom_tool["script"]).to eq("function invoke(params) { return 'test'; }")
|
|
expect(custom_tool["parameters"]).to eq(
|
|
[{ "name" => "query", "type" => "string", "required" => true }],
|
|
)
|
|
end
|
|
|
|
it "handles agents without custom tools" do
|
|
agent = Fabricate(:ai_agent, tools: ["SearchCommand"])
|
|
|
|
get "/admin/plugins/discourse-ai/ai-agents/#{agent.id}/export.json"
|
|
|
|
expect(response).to be_successful
|
|
json = response.parsed_body
|
|
expect(json["custom_tools"]).to eq([])
|
|
expect(json["agent"]["tools"]).to eq(["SearchCommand"])
|
|
end
|
|
|
|
it "returns 404 for non-existent agent" do
|
|
get "/admin/plugins/discourse-ai/ai-agents/999999/export.json"
|
|
expect(response).to have_http_status(:not_found)
|
|
end
|
|
end
|
|
|
|
describe "POST #import" do
|
|
let(:valid_import_data) do
|
|
{
|
|
meta: {
|
|
version: "1.0",
|
|
exported_at: Time.zone.now.iso8601,
|
|
},
|
|
agent: {
|
|
name: "ImportedAgent",
|
|
description: "An imported agent",
|
|
system_prompt: "You are an imported assistant",
|
|
temperature: 0.7,
|
|
top_p: 0.8,
|
|
response_format: [{ type: "string", key: "answer" }],
|
|
examples: [["hello", "hi there"]],
|
|
tools: ["SearchCommand", ["ReadCommand", { max_length: 1000 }, true]],
|
|
},
|
|
custom_tools: [],
|
|
}
|
|
end
|
|
|
|
it "imports a new agent successfully" do
|
|
expect {
|
|
post "/admin/plugins/discourse-ai/ai-agents/import.json",
|
|
params: valid_import_data,
|
|
as: :json
|
|
expect(response).to have_http_status(:created)
|
|
}.to change(AiAgent, :count).by(1)
|
|
|
|
agent = AiAgent.find_by(name: "ImportedAgent")
|
|
expect(agent).to be_present
|
|
expect(agent.description).to eq("An imported agent")
|
|
expect(agent.system_prompt).to eq("You are an imported assistant")
|
|
expect(agent.temperature).to eq(0.7)
|
|
expect(agent.top_p).to eq(0.8)
|
|
expect(agent.response_format).to eq([{ "type" => "string", "key" => "answer" }])
|
|
expect(agent.examples).to eq([["hello", "hi there"]])
|
|
expect(agent.tools).to eq(["SearchCommand", ["ReadCommand", { "max_length" => 1000 }, true]])
|
|
end
|
|
|
|
it "imports a agent with custom tools" do
|
|
import_data_with_tools = valid_import_data.deep_dup
|
|
import_data_with_tools[:agent][:tools] = [
|
|
"SearchCommand",
|
|
["custom-my_custom_tool", { param1: "value1" }, false],
|
|
]
|
|
import_data_with_tools[:custom_tools] = [
|
|
{
|
|
identifier: "my_custom_tool",
|
|
name: "My Custom Tool",
|
|
description: "A custom tool for testing",
|
|
tool_name: "my_custom_tool",
|
|
parameters: [{ name: "param1", type: "string", required: true }],
|
|
summary: "Custom tool summary",
|
|
script: "function invoke(params) { return params.param1; }",
|
|
},
|
|
]
|
|
|
|
expect {
|
|
post "/admin/plugins/discourse-ai/ai-agents/import.json",
|
|
params: import_data_with_tools,
|
|
as: :json
|
|
}.to change(AiAgent, :count).by(1).and change(AiTool, :count).by(1)
|
|
|
|
expect(response).to have_http_status(:created)
|
|
|
|
agent = AiAgent.find_by(name: "ImportedAgent")
|
|
expect(agent).to be_present
|
|
|
|
tool = AiTool.find_by(tool_name: "my_custom_tool")
|
|
expect(tool).to be_present
|
|
expect(tool.name).to eq("My Custom Tool")
|
|
expect(tool.description).to eq("A custom tool for testing")
|
|
expect(tool.script).to eq("function invoke(params) { return params.param1; }")
|
|
|
|
# Check that the tool reference uses the ID
|
|
expect(agent.tools).to include(
|
|
"SearchCommand",
|
|
["custom-#{tool.id}", { "param1" => "value1" }, false],
|
|
)
|
|
end
|
|
|
|
it "prevents importing duplicate agents by default" do
|
|
_existing_agent = Fabricate(:ai_agent, name: "ImportedAgent")
|
|
|
|
post "/admin/plugins/discourse-ai/ai-agents/import.json", params: valid_import_data, as: :json
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body["errors"].join).to include("ImportedAgent")
|
|
end
|
|
|
|
it "allows overwriting existing agents with force=true" do
|
|
existing_agent = Fabricate(:ai_agent, name: "ImportedAgent", description: "Old description")
|
|
|
|
import_data = valid_import_data.merge(force: true)
|
|
|
|
expect {
|
|
post "/admin/plugins/discourse-ai/ai-agents/import.json", params: import_data, as: :json
|
|
}.not_to change(AiAgent, :count)
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
|
|
existing_agent.reload
|
|
expect(existing_agent.description).to eq("An imported agent")
|
|
expect(existing_agent.system_prompt).to eq("You are an imported assistant")
|
|
end
|
|
|
|
it "overwrites existing custom tools with force=true when importing a new agent" do
|
|
existing_tool =
|
|
Fabricate(
|
|
:ai_tool,
|
|
name: "Old Community Scanner",
|
|
tool_name: "scan_public_discourse_community",
|
|
description: "Old description",
|
|
parameters: [],
|
|
summary: "Old summary",
|
|
script: "function invoke() { return 'old'; }",
|
|
)
|
|
|
|
import_data_with_tools = valid_import_data.deep_dup.merge(force: true)
|
|
import_data_with_tools[:agent][:tools] = [
|
|
["custom-scan_public_discourse_community", { max_results: 20 }, false],
|
|
]
|
|
import_data_with_tools[:custom_tools] = [
|
|
{
|
|
identifier: "scan_public_discourse_community",
|
|
name: "Community Scanner",
|
|
description: "Scans the public Discourse community",
|
|
tool_name: "scan_public_discourse_community",
|
|
parameters: [{ name: "max_results", type: "integer", required: false }],
|
|
summary: "Returns matching public community topics",
|
|
script: "function invoke(params) { return params.max_results || 10; }",
|
|
},
|
|
]
|
|
|
|
initial_tool_count = AiTool.count
|
|
|
|
expect {
|
|
post "/admin/plugins/discourse-ai/ai-agents/import.json",
|
|
params: import_data_with_tools,
|
|
as: :json
|
|
}.to change(AiAgent, :count).by(1)
|
|
|
|
expect(response).to have_http_status(:created)
|
|
expect(AiTool.count).to eq(initial_tool_count)
|
|
|
|
agent = AiAgent.find_by(name: "ImportedAgent")
|
|
expect(agent).to be_present
|
|
|
|
existing_tool.reload
|
|
expect(existing_tool.name).to eq("Community Scanner")
|
|
expect(existing_tool.description).to eq("Scans the public Discourse community")
|
|
expect(existing_tool.summary).to eq("Returns matching public community topics")
|
|
expect(existing_tool.script).to eq(
|
|
"function invoke(params) { return params.max_results || 10; }",
|
|
)
|
|
|
|
expect(agent.tools).to eq([["custom-#{existing_tool.id}", { "max_results" => 20 }, false]])
|
|
end
|
|
|
|
it "handles invalid import data gracefully" do
|
|
invalid_data = { invalid: "data" }
|
|
|
|
post "/admin/plugins/discourse-ai/ai-agents/import.json", params: invalid_data, as: :json
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body["errors"]).to be_present
|
|
end
|
|
|
|
it "handles missing agent data" do
|
|
invalid_data = { meta: { version: "1.0" } }
|
|
|
|
post "/admin/plugins/discourse-ai/ai-agents/import.json", params: invalid_data, as: :json
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
end
|
|
|
|
context "with legacy persona format" do
|
|
let(:legacy_import_data) do
|
|
{
|
|
meta: {
|
|
version: "1.0",
|
|
exported_at: Time.zone.now.iso8601,
|
|
},
|
|
persona: {
|
|
name: "LegacyPersona",
|
|
description: "A legacy persona import",
|
|
system_prompt: "You are a legacy assistant",
|
|
temperature: 0.5,
|
|
top_p: 0.9,
|
|
response_format: [],
|
|
examples: [],
|
|
tools: ["SearchCommand"],
|
|
},
|
|
custom_tools: [],
|
|
}
|
|
end
|
|
|
|
it "imports a legacy persona payload" do
|
|
expect {
|
|
post "/admin/plugins/discourse-ai/ai-agents/import.json",
|
|
params: legacy_import_data,
|
|
as: :json
|
|
expect(response).to have_http_status(:created)
|
|
}.to change(AiAgent, :count).by(1)
|
|
|
|
agent = AiAgent.find_by(name: "LegacyPersona")
|
|
expect(agent).to be_present
|
|
expect(agent.description).to eq("A legacy persona import")
|
|
end
|
|
|
|
it "detects conflicts with legacy persona payload" do
|
|
Fabricate(:ai_agent, name: "LegacyPersona")
|
|
|
|
post "/admin/plugins/discourse-ai/ai-agents/import.json",
|
|
params: legacy_import_data,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body["errors"].join).to include("LegacyPersona")
|
|
end
|
|
|
|
it "overwrites with force=true on legacy persona payload" do
|
|
existing = Fabricate(:ai_agent, name: "LegacyPersona", description: "Old")
|
|
|
|
post "/admin/plugins/discourse-ai/ai-agents/import.json",
|
|
params: legacy_import_data.merge(force: true),
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
existing.reload
|
|
expect(existing.description).to eq("A legacy persona import")
|
|
end
|
|
end
|
|
|
|
it "logs staff action when importing a new agent" do
|
|
post "/admin/plugins/discourse-ai/ai-agents/import.json", params: valid_import_data, as: :json
|
|
|
|
expect(response).to have_http_status(:created)
|
|
|
|
history =
|
|
UserHistory.where(
|
|
action: UserHistory.actions[:custom_staff],
|
|
custom_type: "create_ai_agent",
|
|
).last
|
|
expect(history).to be_present
|
|
expect(history.subject).to eq("ImportedAgent")
|
|
end
|
|
|
|
it "logs staff action when updating an existing agent" do
|
|
_existing_agent = Fabricate(:ai_agent, name: "ImportedAgent")
|
|
|
|
import_data = valid_import_data.merge(force: true)
|
|
|
|
post "/admin/plugins/discourse-ai/ai-agents/import.json", params: import_data, as: :json
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
|
|
history =
|
|
UserHistory.where(
|
|
action: UserHistory.actions[:custom_staff],
|
|
custom_type: "update_ai_agent",
|
|
).last
|
|
expect(history).to be_present
|
|
expect(history.subject).to eq("ImportedAgent")
|
|
end
|
|
end
|
|
|
|
describe "DELETE #destroy" do
|
|
it "destroys the requested ai_agent" do
|
|
expect {
|
|
delete "/admin/plugins/discourse-ai/ai-agents/#{ai_agent.id}.json"
|
|
|
|
expect(response).to have_http_status(:no_content)
|
|
}.to change(AiAgent, :count).by(-1)
|
|
end
|
|
|
|
it "logs staff action when deleting a agent" do
|
|
# Capture agent details before deletion
|
|
_agent_id = ai_agent.id
|
|
agent_name = ai_agent.name
|
|
|
|
# Delete the agent
|
|
delete "/admin/plugins/discourse-ai/ai-agents/#{ai_agent.id}.json"
|
|
expect(response).to have_http_status(:no_content)
|
|
|
|
# Now verify the log was created with the right subject
|
|
history =
|
|
UserHistory.where(
|
|
action: UserHistory.actions[:custom_staff],
|
|
custom_type: "delete_ai_agent",
|
|
).last
|
|
expect(history).to be_present
|
|
expect(history.subject).to eq(agent_name) # Verify subject is set to name
|
|
end
|
|
|
|
it "is not allowed to delete system agents" do
|
|
expect {
|
|
delete "/admin/plugins/discourse-ai/ai-agents/#{DiscourseAi::Agents::Agent.system_agents.values.first}.json"
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body["errors"].join).not_to be_blank
|
|
# let's make sure this is translated
|
|
expect(response.parsed_body["errors"].join).not_to include("en.discourse")
|
|
}.not_to change(AiAgent, :count)
|
|
end
|
|
end
|
|
|
|
describe "#stream_reply" do
|
|
fab!(:llm) { Fabricate(:llm_model, name: "fake_llm", provider: "fake") }
|
|
let(:fake_endpoint) { DiscourseAi::Completions::Endpoints::Fake }
|
|
|
|
before { fake_endpoint.delays = [] }
|
|
|
|
after { fake_endpoint.reset! }
|
|
|
|
it "ensures agent exists" do
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json"
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
# this ensures localization key is actually in the yaml
|
|
expect(response.body).to include("agent_name")
|
|
end
|
|
|
|
it "ensures question exists" do
|
|
ai_agent.update!(default_llm_id: llm.id)
|
|
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
agent_id: ai_agent.id,
|
|
user_unique_id: "site:test.com:user_id:1",
|
|
}
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.body).to include("query")
|
|
end
|
|
|
|
it "ensure agent has a user specified" do
|
|
ai_agent.update!(default_llm_id: llm.id)
|
|
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
agent_id: ai_agent.id,
|
|
query: "how are you today?",
|
|
user_unique_id: "site:test.com:user_id:1",
|
|
}
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.body).to include("associated")
|
|
end
|
|
|
|
def parse_streamed_response(raw_http)
|
|
lines = raw_http.split("\r\n")
|
|
|
|
header_lines, _, payload_lines = lines.chunk { |l| l == "" }.map(&:last)
|
|
|
|
preamble = <<~PREAMBLE.strip
|
|
HTTP/1.1 200 OK
|
|
Content-Type: text/plain; charset=utf-8
|
|
Transfer-Encoding: chunked
|
|
Cache-Control: no-cache, no-store, must-revalidate
|
|
Connection: close
|
|
X-Accel-Buffering: no
|
|
X-Content-Type-Options: nosniff
|
|
PREAMBLE
|
|
|
|
expect(header_lines.join("\n")).to eq(preamble)
|
|
|
|
parsed = +""
|
|
chunks = []
|
|
context_info = nil
|
|
|
|
payload_lines.each_slice(2) do |size, data|
|
|
size = size.to_i(16)
|
|
data = data.to_s
|
|
expect(data.bytesize).to eq(size)
|
|
|
|
if size > 0
|
|
json = JSON.parse(data)
|
|
chunks << json
|
|
parsed << json["partial"].to_s
|
|
|
|
context_info = json if json["topic_id"]
|
|
end
|
|
end
|
|
|
|
{ parsed: parsed, context_info: context_info, chunks: chunks }
|
|
end
|
|
|
|
def validate_streamed_response(raw_http, expected)
|
|
response = parse_streamed_response(raw_http)
|
|
expect(response[:parsed]).to eq(expected)
|
|
response[:context_info]
|
|
end
|
|
|
|
it "is able to create a new conversation" do
|
|
Jobs.run_immediately!
|
|
# trust level 0
|
|
SiteSetting.ai_bot_allowed_groups = "10"
|
|
|
|
fake_endpoint.fake_content = ["This is a test! Testing!", "An amazing title"]
|
|
|
|
ai_agent.create_user!
|
|
ai_agent.update!(
|
|
allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]],
|
|
default_llm_id: llm.id,
|
|
allow_personal_messages: true,
|
|
system_prompt: "you are a helpful bot",
|
|
)
|
|
|
|
io_out, io_in = IO.pipe
|
|
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
agent_name: ai_agent.name,
|
|
query: "how are you today?",
|
|
user_unique_id: "site:test.com:user_id:1",
|
|
preferred_username: "test_user",
|
|
custom_instructions: "To be appended to system prompt",
|
|
},
|
|
env: {
|
|
"rack.hijack" => lambda { io_in },
|
|
}
|
|
|
|
# this is a fake response but it catches errors
|
|
expect(response).to have_http_status(:no_content)
|
|
|
|
raw = io_out.read
|
|
context_info = validate_streamed_response(raw, "This is a test! Testing!")
|
|
|
|
system_prompt = fake_endpoint.previous_calls[-2][:dialect].prompt.messages.first[:content]
|
|
|
|
expect(system_prompt).to eq("you are a helpful bot\nTo be appended to system prompt")
|
|
|
|
expect(context_info["topic_id"]).to be_present
|
|
topic = Topic.find(context_info["topic_id"])
|
|
last_post = topic.posts.order(:created_at).last
|
|
expect(last_post.raw).to eq("This is a test! Testing!")
|
|
|
|
user_post = topic.posts.find_by(post_number: 1)
|
|
expect(user_post.raw).to eq("how are you today?")
|
|
|
|
# need ai agent and user
|
|
expect(topic.topic_allowed_users.count).to eq(2)
|
|
expect(topic.archetype).to eq(Archetype.private_message)
|
|
expect(topic.title).to eq("An amazing title")
|
|
expect(topic.posts.count).to eq(2)
|
|
|
|
tool_call =
|
|
DiscourseAi::Completions::ToolCall.new(name: "categories", parameters: {}, id: "tool_1")
|
|
|
|
fake_endpoint.fake_content = [tool_call, "this is the response after the tool"]
|
|
# this simplifies function calls
|
|
fake_endpoint.chunk_count = 1
|
|
|
|
ai_agent.update!(tools: ["Categories"], show_thinking: true)
|
|
|
|
# lets also unstage the user and add the user to tl0
|
|
# this will ensure there are no feedback loops
|
|
new_user = user_post.user
|
|
new_user.update!(staged: false)
|
|
Group.user_trust_level_change!(new_user.id, new_user.trust_level)
|
|
|
|
# double check this happened and user is in group
|
|
agents = AiAgent.allowed_modalities(user: new_user.reload, allow_personal_messages: true)
|
|
expect(agents.count).to eq(1)
|
|
|
|
io_out, io_in = IO.pipe
|
|
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
agent_id: ai_agent.id,
|
|
query: "how are you now?",
|
|
user_unique_id: "site:test.com:user_id:1",
|
|
preferred_username: "test_user",
|
|
topic_id: context_info["topic_id"],
|
|
},
|
|
env: {
|
|
"rack.hijack" => lambda { io_in },
|
|
}
|
|
|
|
# this is a fake response but it catches errors
|
|
expect(response).to have_http_status(:no_content)
|
|
|
|
raw = io_out.read
|
|
context_info = validate_streamed_response(raw, "this is the response after the tool")
|
|
|
|
topic = topic.reload
|
|
last_post = topic.posts.order(:created_at).last
|
|
|
|
expect(last_post.raw).to end_with("this is the response after the tool")
|
|
# function call is visible in the post
|
|
expect(last_post.raw[0..8]).to eq("<details ")
|
|
expect(last_post.raw).to include("ai-thinking")
|
|
|
|
user_post = topic.posts.find_by(post_number: 3)
|
|
expect(user_post.raw).to eq("how are you now?")
|
|
expect(user_post.user.username).to eq("test_user")
|
|
expect(user_post.user.custom_fields).to eq(
|
|
{ "ai-stream-conversation-unique-id" => "site:test.com:user_id:1" },
|
|
)
|
|
|
|
expect(topic.posts.count).to eq(4)
|
|
end
|
|
|
|
it "supports custom injected tools with resume tokens" do
|
|
Jobs.run_immediately!
|
|
SiteSetting.ai_bot_allowed_groups = "10"
|
|
|
|
ai_agent.create_user!
|
|
ai_agent.update!(
|
|
allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]],
|
|
default_llm_id: llm.id,
|
|
allow_personal_messages: true,
|
|
system_prompt: "you are a helpful bot",
|
|
)
|
|
|
|
fake_endpoint.fake_content = [
|
|
DiscourseAi::Completions::ToolCall.new(
|
|
name: "client_weather",
|
|
parameters: {
|
|
city: "Austin",
|
|
},
|
|
id: "tool_1",
|
|
),
|
|
"This is the response after a client tool call.",
|
|
"Tool flow title",
|
|
]
|
|
fake_endpoint.chunk_count = 1
|
|
|
|
io_out, io_in = IO.pipe
|
|
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
agent_id: ai_agent.id,
|
|
query: "What's the weather?",
|
|
user_unique_id: "site:test.com:user_id:42",
|
|
preferred_username: "tool_user",
|
|
custom_tools: [
|
|
{
|
|
name: "client_weather",
|
|
description: "Gets weather from the client runtime",
|
|
parameters: [
|
|
{
|
|
name: "city",
|
|
description: "City to fetch weather for",
|
|
type: "string",
|
|
required: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
env: {
|
|
"rack.hijack" => lambda { io_in },
|
|
}
|
|
|
|
expect(response).to have_http_status(:no_content)
|
|
|
|
parsed = parse_streamed_response(io_out.read)
|
|
context = parsed[:context_info]
|
|
tool_event = parsed[:chunks].find { |chunk| chunk["event"] == "tool_calls" }
|
|
|
|
expect(parsed[:parsed]).to eq("")
|
|
expect(context["topic_id"]).to be_present
|
|
expect(tool_event).to be_present
|
|
expect(tool_event.dig("tool_calls", 0, "name")).to eq("client_weather")
|
|
expect(tool_event["resume_token"]).to be_present
|
|
|
|
topic = Topic.find(context["topic_id"])
|
|
expect(topic.posts.count).to eq(1)
|
|
expect(topic.posts.first.raw).to eq("What's the weather?")
|
|
|
|
io_out, io_in = IO.pipe
|
|
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
resume_token: tool_event["resume_token"],
|
|
tool_results: [{ tool_call_id: "tool_1", content: { temperature_c: 23 } }],
|
|
},
|
|
env: {
|
|
"rack.hijack" => lambda { io_in },
|
|
}
|
|
|
|
expect(response).to have_http_status(:no_content)
|
|
|
|
resumed = parse_streamed_response(io_out.read)
|
|
expect(resumed[:parsed]).to eq("This is the response after a client tool call.")
|
|
|
|
topic.reload
|
|
expect(topic.posts.count).to eq(2)
|
|
expect(topic.posts.order(:created_at).last.raw).to eq(
|
|
"This is the response after a client tool call.",
|
|
)
|
|
expect(topic.title).to eq("Tool flow title")
|
|
end
|
|
|
|
it "validates resume requests include tool_results" do
|
|
resume_token = SecureRandom.hex(12)
|
|
Discourse.redis.setex(
|
|
DiscourseAi::AiBot::StreamReplyCustomToolsSession.redis_key(resume_token),
|
|
60,
|
|
{ prompt: { messages: [], tools: [] } }.to_json,
|
|
)
|
|
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
resume_token: resume_token,
|
|
}
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body["errors"].join).to include("tool_results")
|
|
end
|
|
|
|
it "supports parallel tool calls in one completion turn" do
|
|
Jobs.run_immediately!
|
|
SiteSetting.ai_bot_allowed_groups = "10"
|
|
|
|
ai_agent.create_user!
|
|
ai_agent.update!(
|
|
allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]],
|
|
default_llm_id: llm.id,
|
|
allow_personal_messages: true,
|
|
system_prompt: "you are a helpful bot",
|
|
)
|
|
|
|
user = Fabricate(:user)
|
|
Group.user_trust_level_change!(user.id, user.trust_level)
|
|
|
|
first_post =
|
|
PostCreator.create!(
|
|
user,
|
|
title: "Parallel Tool Topic",
|
|
archetype: Archetype.private_message,
|
|
target_usernames: ai_agent.user.username,
|
|
raw: "Initial context message",
|
|
custom_fields: {
|
|
DiscourseAi::AiBot::Playground::BYPASS_AI_REPLY_CUSTOM_FIELD => true,
|
|
},
|
|
)
|
|
topic = first_post.topic
|
|
|
|
tool_1 =
|
|
DiscourseAi::Completions::ToolCall.new(
|
|
name: "client_weather",
|
|
parameters: {
|
|
city: "Austin",
|
|
},
|
|
id: "tool_1",
|
|
)
|
|
|
|
tool_2 =
|
|
DiscourseAi::Completions::ToolCall.new(
|
|
name: "client_time",
|
|
parameters: {
|
|
timezone: "America/Chicago",
|
|
},
|
|
id: "tool_2",
|
|
)
|
|
|
|
fake_endpoint.fake_content = [[tool_1, tool_2], "Parallel tool response finished."]
|
|
|
|
io_out, io_in = IO.pipe
|
|
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
agent_id: ai_agent.id,
|
|
username: user.username,
|
|
topic_id: topic.id,
|
|
query: "Need weather and local time.",
|
|
custom_tools: [
|
|
{
|
|
name: "client_weather",
|
|
description: "Gets weather from a client runtime",
|
|
parameters: [
|
|
{
|
|
name: "city",
|
|
description: "City to fetch weather for",
|
|
type: "string",
|
|
required: true,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "client_time",
|
|
description: "Gets local time from a client runtime",
|
|
parameters: [
|
|
{
|
|
name: "timezone",
|
|
description: "IANA timezone string",
|
|
type: "string",
|
|
required: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
env: {
|
|
"rack.hijack" => lambda { io_in },
|
|
}
|
|
|
|
expect(response).to have_http_status(:no_content)
|
|
|
|
parsed = parse_streamed_response(io_out.read)
|
|
tool_event = parsed[:chunks].find { |chunk| chunk["event"] == "tool_calls" }
|
|
|
|
expect(parsed[:parsed]).to eq("")
|
|
expect(tool_event).to be_present
|
|
expect(tool_event["tool_calls"].length).to eq(2)
|
|
expect(tool_event["tool_calls"].map { |call| call["id"] }).to contain_exactly(
|
|
"tool_1",
|
|
"tool_2",
|
|
)
|
|
expect(tool_event["resume_token"]).to be_present
|
|
|
|
io_out, io_in = IO.pipe
|
|
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
resume_token: tool_event["resume_token"],
|
|
tool_results: [
|
|
{ tool_call_id: "tool_2", content: { local_time: "10:15" } },
|
|
{ tool_call_id: "tool_1", content: { temperature_c: 23 } },
|
|
],
|
|
},
|
|
env: {
|
|
"rack.hijack" => lambda { io_in },
|
|
}
|
|
|
|
expect(response).to have_http_status(:no_content)
|
|
|
|
resumed = parse_streamed_response(io_out.read)
|
|
expect(resumed[:parsed]).to eq("Parallel tool response finished.")
|
|
|
|
topic.reload
|
|
expect(topic.posts.count).to eq(3)
|
|
expect(topic.posts.order(:created_at).last.raw).to eq("Parallel tool response finished.")
|
|
end
|
|
|
|
it "rejects too many custom tools" do
|
|
tools =
|
|
Array.new(21) do |index|
|
|
{ name: "tool_#{index}", description: "test tool #{index}", parameters: [] }
|
|
end
|
|
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
custom_tools: tools,
|
|
}
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body["errors"].join).to include("custom_tools")
|
|
end
|
|
|
|
it "rejects ambiguous tool_results params" do
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
tool_result: {
|
|
tool_call_id: "tool_1",
|
|
content: "single",
|
|
},
|
|
tool_results: [{ tool_call_id: "tool_2", content: "plural" }],
|
|
}
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body["errors"].join).to include("either tool_results or tool_result")
|
|
end
|
|
|
|
it "rejects oversized tool result content" do
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
resume_token: "ignored-by-validation",
|
|
tool_results: [{ tool_call_id: "tool_1", content: "a" * 103_000 }],
|
|
}
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body["errors"].join).to include("at most")
|
|
end
|
|
|
|
it "rejects nil tool result content" do
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
tool_results: [{ tool_call_id: "tool_1", content: nil }],
|
|
}
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body["errors"].join).to include("tool_results")
|
|
end
|
|
|
|
it "handles resume token TOCTOU expiration during session load" do
|
|
allow(DiscourseAi::AiBot::StreamReplyCustomToolsSession).to receive(
|
|
:resume_state_exists?,
|
|
).and_call_original
|
|
allow(DiscourseAi::AiBot::StreamReplyCustomToolsSession).to receive(
|
|
:resume_state_exists?,
|
|
).with("toctou-token").and_return(true)
|
|
|
|
io_out, io_in = IO.pipe
|
|
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
resume_token: "toctou-token",
|
|
tool_results: [{ tool_call_id: "tool_1", content: { ok: true } }],
|
|
},
|
|
env: {
|
|
"rack.hijack" => lambda { io_in },
|
|
}
|
|
|
|
expect(response).to have_http_status(:no_content)
|
|
|
|
parsed = parse_streamed_response(io_out.read)
|
|
error_event = parsed[:chunks].find { |chunk| chunk["event"] == "error" }
|
|
|
|
expect(error_event).to be_present
|
|
expect(error_event["error"]).to include("resume_token")
|
|
end
|
|
|
|
it "rejects resume tokens from a different admin user" do
|
|
Jobs.run_immediately!
|
|
SiteSetting.ai_bot_allowed_groups = "10"
|
|
|
|
ai_agent.create_user!
|
|
ai_agent.update!(
|
|
allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]],
|
|
default_llm_id: llm.id,
|
|
allow_personal_messages: true,
|
|
system_prompt: "you are a helpful bot",
|
|
)
|
|
|
|
fake_endpoint.fake_content = [
|
|
DiscourseAi::Completions::ToolCall.new(
|
|
name: "client_weather",
|
|
parameters: {
|
|
city: "Austin",
|
|
},
|
|
id: "tool_1",
|
|
),
|
|
]
|
|
|
|
io_out, io_in = IO.pipe
|
|
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
agent_id: ai_agent.id,
|
|
query: "Need weather",
|
|
user_unique_id: "site:test.com:user_id:mismatch",
|
|
preferred_username: "mismatch_user",
|
|
custom_tools: [
|
|
{
|
|
name: "client_weather",
|
|
description: "Gets weather from the client runtime",
|
|
parameters: [
|
|
{
|
|
name: "city",
|
|
description: "City to fetch weather for",
|
|
type: "string",
|
|
required: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
env: {
|
|
"rack.hijack" => lambda { io_in },
|
|
}
|
|
|
|
expect(response).to have_http_status(:no_content)
|
|
parsed = parse_streamed_response(io_out.read)
|
|
resume_token = parsed[:chunks].find { |chunk| chunk["event"] == "tool_calls" }["resume_token"]
|
|
|
|
sign_in(admin_2)
|
|
io_out, io_in = IO.pipe
|
|
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
resume_token: resume_token,
|
|
tool_results: [{ tool_call_id: "tool_1", content: { temp_c: 22 } }],
|
|
},
|
|
env: {
|
|
"rack.hijack" => lambda { io_in },
|
|
}
|
|
|
|
expect(response).to have_http_status(:no_content)
|
|
resumed = parse_streamed_response(io_out.read)
|
|
error_event = resumed[:chunks].find { |chunk| chunk["event"] == "error" }
|
|
|
|
expect(error_event).to be_present
|
|
expect(error_event["error"]).to include("resume_token")
|
|
end
|
|
|
|
it "rejects unexpected tool_call_ids in tool results" do
|
|
Jobs.run_immediately!
|
|
SiteSetting.ai_bot_allowed_groups = "10"
|
|
|
|
ai_agent.create_user!
|
|
ai_agent.update!(
|
|
allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]],
|
|
default_llm_id: llm.id,
|
|
allow_personal_messages: true,
|
|
system_prompt: "you are a helpful bot",
|
|
)
|
|
|
|
fake_endpoint.fake_content = [
|
|
DiscourseAi::Completions::ToolCall.new(
|
|
name: "client_weather",
|
|
parameters: {
|
|
},
|
|
id: "tool_1",
|
|
),
|
|
]
|
|
|
|
io_out, io_in = IO.pipe
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
agent_id: ai_agent.id,
|
|
query: "Need weather",
|
|
user_unique_id: "site:test.com:user_id:unexpected",
|
|
preferred_username: "unexpected_user",
|
|
custom_tools: [
|
|
{
|
|
name: "client_weather",
|
|
description: "Gets weather from the client runtime",
|
|
parameters: [],
|
|
},
|
|
],
|
|
},
|
|
env: {
|
|
"rack.hijack" => lambda { io_in },
|
|
}
|
|
|
|
expect(response).to have_http_status(:no_content)
|
|
parsed = parse_streamed_response(io_out.read)
|
|
resume_token = parsed[:chunks].find { |chunk| chunk["event"] == "tool_calls" }["resume_token"]
|
|
|
|
io_out, io_in = IO.pipe
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
resume_token: resume_token,
|
|
tool_results: [
|
|
{ tool_call_id: "tool_1", content: { ok: true } },
|
|
{ tool_call_id: "tool_extra", content: { bad: true } },
|
|
],
|
|
},
|
|
env: {
|
|
"rack.hijack" => lambda { io_in },
|
|
}
|
|
|
|
expect(response).to have_http_status(:no_content)
|
|
resumed = parse_streamed_response(io_out.read)
|
|
error_event = resumed[:chunks].find { |chunk| chunk["event"] == "error" }
|
|
|
|
expect(error_event).to be_present
|
|
expect(error_event["error"]).to include("Unexpected")
|
|
end
|
|
|
|
it "supports multi-round tool calling with resume" do
|
|
Jobs.run_immediately!
|
|
SiteSetting.ai_bot_allowed_groups = "10"
|
|
|
|
ai_agent.create_user!
|
|
ai_agent.update!(
|
|
allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]],
|
|
default_llm_id: llm.id,
|
|
allow_personal_messages: true,
|
|
system_prompt: "you are a helpful bot",
|
|
)
|
|
|
|
user = Fabricate(:user)
|
|
Group.user_trust_level_change!(user.id, user.trust_level)
|
|
|
|
first_post =
|
|
PostCreator.create!(
|
|
user,
|
|
title: "Multi Round Tool Topic",
|
|
archetype: Archetype.private_message,
|
|
target_usernames: ai_agent.user.username,
|
|
raw: "Initial context message",
|
|
custom_fields: {
|
|
DiscourseAi::AiBot::Playground::BYPASS_AI_REPLY_CUSTOM_FIELD => true,
|
|
},
|
|
)
|
|
topic = first_post.topic
|
|
|
|
fake_endpoint.fake_content = [
|
|
DiscourseAi::Completions::ToolCall.new(
|
|
name: "client_weather",
|
|
parameters: {
|
|
},
|
|
id: "tool_1",
|
|
),
|
|
DiscourseAi::Completions::ToolCall.new(name: "client_time", parameters: {}, id: "tool_2"),
|
|
"Finished after two tool rounds.",
|
|
]
|
|
|
|
io_out, io_in = IO.pipe
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
agent_id: ai_agent.id,
|
|
username: user.username,
|
|
topic_id: topic.id,
|
|
query: "Need weather and time.",
|
|
custom_tools: [
|
|
{
|
|
name: "client_weather",
|
|
description: "Gets weather from a client runtime",
|
|
parameters: [],
|
|
},
|
|
{
|
|
name: "client_time",
|
|
description: "Gets local time from a client runtime",
|
|
parameters: [],
|
|
},
|
|
],
|
|
},
|
|
env: {
|
|
"rack.hijack" => lambda { io_in },
|
|
}
|
|
|
|
expect(response).to have_http_status(:no_content)
|
|
first_round = parse_streamed_response(io_out.read)
|
|
first_tool_event = first_round[:chunks].find { |chunk| chunk["event"] == "tool_calls" }
|
|
|
|
expect(first_tool_event.dig("tool_calls", 0, "id")).to eq("tool_1")
|
|
|
|
io_out, io_in = IO.pipe
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
resume_token: first_tool_event["resume_token"],
|
|
tool_results: [{ tool_call_id: "tool_1", content: { temp_c: 22 } }],
|
|
},
|
|
env: {
|
|
"rack.hijack" => lambda { io_in },
|
|
}
|
|
|
|
expect(response).to have_http_status(:no_content)
|
|
second_round = parse_streamed_response(io_out.read)
|
|
second_tool_event = second_round[:chunks].find { |chunk| chunk["event"] == "tool_calls" }
|
|
|
|
expect(second_tool_event.dig("tool_calls", 0, "id")).to eq("tool_2")
|
|
|
|
io_out, io_in = IO.pipe
|
|
post "/admin/plugins/discourse-ai/ai-agents/stream-reply.json",
|
|
params: {
|
|
resume_token: second_tool_event["resume_token"],
|
|
tool_results: [{ tool_call_id: "tool_2", content: { time: "10:15" } }],
|
|
},
|
|
env: {
|
|
"rack.hijack" => lambda { io_in },
|
|
}
|
|
|
|
expect(response).to have_http_status(:no_content)
|
|
final_round = parse_streamed_response(io_out.read)
|
|
|
|
expect(final_round[:parsed]).to eq("Finished after two tool rounds.")
|
|
topic.reload
|
|
expect(topic.posts.count).to eq(3)
|
|
expect(topic.posts.order(:created_at).last.raw).to eq("Finished after two tool rounds.")
|
|
end
|
|
end
|
|
end
|