mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-06-19 12:14:16 +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>
256 lines
8 KiB
Ruby
Vendored
256 lines
8 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
RSpec.describe DiscourseAi::Completions::Dialects::Claude do
|
|
fab!(:llm_model) { Fabricate(:anthropic_model, name: "claude-3-opus") }
|
|
|
|
let(:opus_dialect_klass) { DiscourseAi::Completions::Dialects::Dialect.dialect_for(llm_model) }
|
|
|
|
before { enable_current_plugin }
|
|
|
|
describe "#translate" do
|
|
it "can insert OKs to make stuff interleve properly" do
|
|
messages = [
|
|
{ type: :user, id: "user1", content: "1" },
|
|
{ type: :model, content: "2" },
|
|
{ type: :user, id: "user1", content: "4" },
|
|
{ type: :user, id: "user1", content: "5" },
|
|
{ type: :model, content: "6" },
|
|
]
|
|
|
|
prompt = DiscourseAi::Completions::Prompt.new("You are a helpful bot", messages: messages)
|
|
|
|
dialect = opus_dialect_klass.new(prompt, llm_model)
|
|
translated = dialect.translate
|
|
|
|
expected_messages = [
|
|
{ role: "user", content: "user1: 1" },
|
|
{ role: "assistant", content: "2" },
|
|
{ role: "user", content: "user1: 4" },
|
|
{ role: "assistant", content: "OK" },
|
|
{ role: "user", content: "user1: 5" },
|
|
{ role: "assistant", content: "6" },
|
|
]
|
|
|
|
expect(translated.messages).to eq(expected_messages)
|
|
end
|
|
|
|
it "can properly translate a prompt (legacy tools)" do
|
|
llm_model.provider_params["disable_native_tools"] = true
|
|
llm_model.save!
|
|
|
|
tools = [
|
|
{
|
|
name: "echo",
|
|
description: "echo a string",
|
|
parameters: [
|
|
{ name: "text", type: "string", description: "string to echo", required: true },
|
|
],
|
|
},
|
|
]
|
|
|
|
tool_call_prompt = { name: "echo", arguments: { text: "something" } }
|
|
|
|
messages = [
|
|
{ type: :user, id: "user1", content: "echo something" },
|
|
{ type: :tool_call, name: "echo", id: "tool_id", content: tool_call_prompt.to_json },
|
|
{ type: :tool, id: "tool_id", content: "something".to_json },
|
|
{ type: :model, content: "I did it" },
|
|
{ type: :user, id: "user1", content: "echo something else" },
|
|
]
|
|
|
|
prompt =
|
|
DiscourseAi::Completions::Prompt.new(
|
|
"You are a helpful bot",
|
|
messages: messages,
|
|
tools: tools,
|
|
)
|
|
|
|
dialect = opus_dialect_klass.new(prompt, llm_model)
|
|
translated = dialect.translate
|
|
|
|
expect(translated.system_prompt).to start_with("You are a helpful bot")
|
|
|
|
expected = [
|
|
{ role: "user", content: "user1: echo something" },
|
|
{
|
|
role: "assistant",
|
|
content:
|
|
"<function_calls>\n<invoke>\n<tool_name>echo</tool_name>\n<parameters>\n<text>something</text>\n</parameters>\n</invoke>\n</function_calls>",
|
|
},
|
|
{
|
|
role: "user",
|
|
content:
|
|
"<function_results>\n<result>\n<tool_name>tool_id</tool_name>\n<json>\n\"something\"\n</json>\n</result>\n</function_results>",
|
|
},
|
|
{ role: "assistant", content: "I did it" },
|
|
{ role: "user", content: "user1: echo something else" },
|
|
]
|
|
expect(translated.messages).to eq(expected)
|
|
end
|
|
|
|
it "can properly translate a prompt (native tools)" do
|
|
tools = [
|
|
{
|
|
name: "echo",
|
|
description: "echo a string",
|
|
parameters: [
|
|
{ name: "text", type: "string", description: "string to echo", required: true },
|
|
],
|
|
},
|
|
]
|
|
|
|
tool_call_prompt = { name: "echo", arguments: { text: "something" } }
|
|
|
|
messages = [
|
|
{ type: :user, id: "user1", content: "echo something" },
|
|
{ type: :tool_call, name: "echo", id: "tool_id", content: tool_call_prompt.to_json },
|
|
{ type: :tool, id: "tool_id", content: "something".to_json },
|
|
{ type: :model, content: "I did it" },
|
|
{ type: :user, id: "user1", content: "echo something else" },
|
|
]
|
|
|
|
prompt =
|
|
DiscourseAi::Completions::Prompt.new(
|
|
"You are a helpful bot",
|
|
messages: messages,
|
|
tools: tools,
|
|
)
|
|
dialect = opus_dialect_klass.new(prompt, llm_model)
|
|
translated = dialect.translate
|
|
|
|
expect(translated.system_prompt).to start_with("You are a helpful bot")
|
|
|
|
expected = [
|
|
{ role: "user", content: "user1: echo something" },
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "tool_use", id: "tool_id", name: "echo", input: { text: "something" } },
|
|
],
|
|
},
|
|
{
|
|
role: "user",
|
|
content: [{ type: "tool_result", tool_use_id: "tool_id", content: "\"something\"" }],
|
|
},
|
|
{ role: "assistant", content: "I did it" },
|
|
{ role: "user", content: "user1: echo something else" },
|
|
]
|
|
expect(translated.messages).to eq(expected)
|
|
end
|
|
|
|
it "renders converted document uploads as text" do
|
|
llm_model.update!(allowed_attachment_types: ["docx"])
|
|
converted_text = "Uploaded document: sample.docx (13 Bytes)\n\nConverted text"
|
|
prompt =
|
|
DiscourseAi::Completions::Prompt.new(
|
|
nil,
|
|
messages: [{ type: :user, content: ["Read this: ", { upload_id: 123 }] }],
|
|
)
|
|
|
|
allow(DiscourseAi::Completions::UploadEncoder).to receive(:encode).and_return(
|
|
[
|
|
{
|
|
kind: :document,
|
|
filename: "sample.docx",
|
|
mime_type: "text/plain",
|
|
text: converted_text,
|
|
converted_from: "docx",
|
|
},
|
|
],
|
|
)
|
|
|
|
translated = described_class.new(prompt, llm_model).translate
|
|
content = translated.messages.first[:content]
|
|
|
|
expect(content).to eq(
|
|
[{ type: "text", text: "Read this: " }, { type: "text", text: converted_text }],
|
|
)
|
|
expect(content).not_to include(hash_including(type: "document"))
|
|
end
|
|
end
|
|
|
|
describe "#translate with server tools" do
|
|
it "replays Anthropic server tool content blocks" do
|
|
content_blocks = [
|
|
{ type: "text", text: "I'll search." },
|
|
{
|
|
type: "server_tool_use",
|
|
id: "srvtoolu_1",
|
|
name: "web_search",
|
|
input: {
|
|
query: "HackerOne AI news",
|
|
},
|
|
},
|
|
{
|
|
type: "web_search_tool_result",
|
|
tool_use_id: "srvtoolu_1",
|
|
content: [
|
|
{
|
|
type: "web_search_result",
|
|
url: "https://example.com",
|
|
title: "Example",
|
|
encrypted_content: "encrypted",
|
|
},
|
|
],
|
|
},
|
|
{ type: "text", text: "Result summary" },
|
|
]
|
|
|
|
prompt = DiscourseAi::Completions::Prompt.new("You are a bot")
|
|
prompt.push(type: :user, content: "Search")
|
|
prompt.push(
|
|
type: :model,
|
|
content: "I'll search.Result summary",
|
|
thinking: "Web search: HackerOne AI news",
|
|
thinking_provider_info: {
|
|
anthropic: {
|
|
content_blocks: content_blocks,
|
|
},
|
|
},
|
|
)
|
|
|
|
translated = described_class.new(prompt, llm_model).translate
|
|
model_message = translated.messages.find { |message| message[:role] == "assistant" }
|
|
|
|
expect(model_message[:content]).to eq(content_blocks)
|
|
end
|
|
|
|
it "preserves signed thinking when replaying server tools" do
|
|
content_blocks = [
|
|
{ type: "text", text: "I'll search." },
|
|
{
|
|
type: "server_tool_use",
|
|
id: "srvtoolu_1",
|
|
name: "web_search",
|
|
input: {
|
|
query: "HackerOne AI news",
|
|
},
|
|
},
|
|
]
|
|
|
|
prompt = DiscourseAi::Completions::Prompt.new("You are a bot")
|
|
prompt.push(type: :user, content: "Search")
|
|
prompt.push(
|
|
type: :model,
|
|
content: "I'll search.",
|
|
thinking: "Need current data",
|
|
thinking_provider_info: {
|
|
anthropic: {
|
|
signature: "sig-123",
|
|
content_blocks: content_blocks,
|
|
},
|
|
},
|
|
)
|
|
|
|
translated = described_class.new(prompt, llm_model).translate
|
|
model_message = translated.messages.find { |message| message[:role] == "assistant" }
|
|
|
|
expect(model_message[:content]).to eq(
|
|
[
|
|
{ type: "thinking", thinking: "Need current data", signature: "sig-123" },
|
|
*content_blocks,
|
|
],
|
|
)
|
|
end
|
|
end
|
|
end
|