discourse/plugins/discourse-ai/spec/lib/completions/dialects/claude_spec.rb
Rafael dos Santos Silva bc39aacc3d
FEATURE: Provider-native built-in tools for agents (web search) (#40809)
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>
2026-06-16 14:37:51 -03:00

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