mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-06-30 19:42:17 +00:00
* DEV: use a proper object for tool definition This moves away from using a loose hash to define tools, which is error prone. Instead given a proper object we will also be able to coerce the return values to match tool definition correctly * fix xml tools * fix anthropic tools * fix specs... a few more to go * specs are passing * FIX: coerce values for XML tool calls * Update spec/lib/completions/tool_definition_spec.rb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
339 lines
10 KiB
Ruby
339 lines
10 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
RSpec.describe DiscourseAi::Completions::ToolDefinition do
|
|
# Test case 1: Basic tool definition creation
|
|
describe "#initialize" do
|
|
it "creates a tool with name, description and parameters" do
|
|
param =
|
|
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
|
|
name: "query",
|
|
description: "The search query",
|
|
type: :string,
|
|
required: true,
|
|
)
|
|
|
|
tool =
|
|
described_class.new(
|
|
name: "search_engine",
|
|
description: "Searches the web for information",
|
|
parameters: [param],
|
|
)
|
|
|
|
expect(tool.name).to eq("search_engine")
|
|
expect(tool.description).to eq("Searches the web for information")
|
|
expect(tool.parameters.size).to eq(1)
|
|
expect(tool.parameters.first.name).to eq("query")
|
|
end
|
|
end
|
|
|
|
# Test case 2: Creating tool from hash
|
|
describe ".from_hash" do
|
|
it "creates a tool from a hash representation" do
|
|
hash = {
|
|
name: "calculator",
|
|
description: "Performs math operations",
|
|
parameters: [
|
|
{
|
|
name: "expression",
|
|
description: "Math expression to evaluate",
|
|
type: "string",
|
|
required: true,
|
|
},
|
|
],
|
|
}
|
|
|
|
tool = described_class.from_hash(hash)
|
|
|
|
expect(tool.name).to eq("calculator")
|
|
expect(tool.description).to eq("Performs math operations")
|
|
expect(tool.parameters.size).to eq(1)
|
|
expect(tool.parameters.first.name).to eq("expression")
|
|
expect(tool.parameters.first.type).to eq(:string)
|
|
end
|
|
|
|
it "rejects a hash with extra keys" do
|
|
hash = {
|
|
name: "calculator",
|
|
description: "Performs math operations",
|
|
parameters: [],
|
|
extra_key: "should not be here",
|
|
}
|
|
|
|
expect { described_class.from_hash(hash) }.to raise_error(ArgumentError, /Unexpected keys/)
|
|
end
|
|
end
|
|
|
|
# Test case 3: Parameter with enum validation
|
|
describe DiscourseAi::Completions::ToolDefinition::ParameterDefinition do
|
|
context "with enum values" do
|
|
it "accepts valid enum values matching the type" do
|
|
param =
|
|
described_class.new(
|
|
name: "operation",
|
|
description: "Math operation to perform",
|
|
type: :string,
|
|
enum: %w[add subtract multiply divide],
|
|
)
|
|
|
|
expect(param.enum).to eq(%w[add subtract multiply divide])
|
|
end
|
|
|
|
it "rejects enum values that don't match the specified type" do
|
|
expect {
|
|
described_class.new(
|
|
name: "operation",
|
|
description: "Math operation to perform",
|
|
type: :integer,
|
|
enum: %w[add subtract], # String values for integer type
|
|
)
|
|
}.to raise_error(ArgumentError, /enum values must be integers/)
|
|
end
|
|
end
|
|
|
|
context "with item_type specification" do
|
|
it "only allows item_type for array type parameters" do
|
|
expect {
|
|
described_class.new(
|
|
name: "colors",
|
|
description: "List of colors",
|
|
type: :array,
|
|
item_type: :string,
|
|
)
|
|
}.not_to raise_error
|
|
|
|
expect {
|
|
described_class.new(
|
|
name: "color",
|
|
description: "A single color",
|
|
type: :string,
|
|
item_type: :string,
|
|
)
|
|
}.to raise_error(ArgumentError, /item_type can only be specified for array type/)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Test case 4: Coercing string parameters
|
|
describe "#coerce_parameters" do
|
|
context "with string parameters" do
|
|
let(:tool) do
|
|
param =
|
|
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
|
|
name: "name",
|
|
description: "User's name",
|
|
type: :string,
|
|
)
|
|
|
|
described_class.new(
|
|
name: "greeting",
|
|
description: "Generates a greeting",
|
|
parameters: [param],
|
|
)
|
|
end
|
|
|
|
it "converts numbers to strings" do
|
|
result = tool.coerce_parameters(name: 123)
|
|
expect(result[:name]).to eq("123")
|
|
end
|
|
|
|
it "converts booleans to strings" do
|
|
result = tool.coerce_parameters(name: true)
|
|
expect(result[:name]).to eq("true")
|
|
end
|
|
end
|
|
|
|
# Test case 5: Coercing number parameters
|
|
context "with number parameters" do
|
|
let(:tool) do
|
|
param =
|
|
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
|
|
name: "price",
|
|
description: "Item price",
|
|
type: :number,
|
|
)
|
|
|
|
described_class.new(name: "store", description: "Store operations", parameters: [param])
|
|
end
|
|
|
|
it "converts string numbers to floats" do
|
|
result = tool.coerce_parameters(price: "42.99")
|
|
expect(result[:price]).to eq(42.99)
|
|
end
|
|
|
|
it "converts integers to floats" do
|
|
result = tool.coerce_parameters(price: 42)
|
|
expect(result[:price]).to eq(42.0)
|
|
end
|
|
|
|
it "returns nil for invalid number strings" do
|
|
result = tool.coerce_parameters(price: "not a number")
|
|
expect(result[:price]).to be_nil
|
|
end
|
|
end
|
|
|
|
# Test case 6: Coercing array parameters with item types
|
|
context "with array parameters and item types" do
|
|
let(:tool) do
|
|
param =
|
|
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
|
|
name: "numbers",
|
|
description: "List of numeric values",
|
|
type: :array,
|
|
item_type: :integer,
|
|
)
|
|
|
|
described_class.new(
|
|
name: "stats",
|
|
description: "Statistical operations",
|
|
parameters: [param],
|
|
)
|
|
end
|
|
|
|
it "converts string elements to integers" do
|
|
result = tool.coerce_parameters(numbers: %w[1 2 3])
|
|
expect(result[:numbers]).to eq([1, 2, 3])
|
|
end
|
|
|
|
it "parses JSON strings into arrays and converts elements" do
|
|
result = tool.coerce_parameters(numbers: "[1, 2, 3]")
|
|
expect(result[:numbers]).to eq([1, 2, 3])
|
|
end
|
|
|
|
it "handles mixed type arrays appropriately" do
|
|
result = tool.coerce_parameters(numbers: [1, "two", 3.5])
|
|
expect(result[:numbers]).to eq([1, nil, 3])
|
|
end
|
|
end
|
|
|
|
# Test case 7: Required parameters
|
|
context "with required and optional parameters" do
|
|
let(:tool) do
|
|
param1 =
|
|
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
|
|
name: "required_param",
|
|
description: "This is required",
|
|
type: :string,
|
|
required: true,
|
|
)
|
|
|
|
param2 =
|
|
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
|
|
name: "optional_param",
|
|
description: "This is optional",
|
|
type: :string,
|
|
)
|
|
|
|
described_class.new(
|
|
name: "test_tool",
|
|
description: "Test tool",
|
|
parameters: [param1, param2],
|
|
)
|
|
end
|
|
|
|
it "includes missing required parameters as nil" do
|
|
result = tool.coerce_parameters(optional_param: "value")
|
|
expect(result[:required_param]).to be_nil
|
|
expect(result[:optional_param]).to eq("value")
|
|
end
|
|
|
|
it "skips missing optional parameters" do
|
|
result = tool.coerce_parameters({})
|
|
expect(result[:required_param]).to be_nil
|
|
expect(result.key?("optional_param")).to be false
|
|
end
|
|
end
|
|
|
|
# Test case 8: Boolean parameter coercion
|
|
context "with boolean parameters" do
|
|
let(:tool) do
|
|
param =
|
|
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
|
|
name: "flag",
|
|
description: "Boolean flag",
|
|
type: :boolean,
|
|
)
|
|
|
|
described_class.new(name: "feature", description: "Feature toggle", parameters: [param])
|
|
end
|
|
|
|
it "preserves true/false values" do
|
|
result = tool.coerce_parameters(flag: true)
|
|
expect(result[:flag]).to be true
|
|
end
|
|
|
|
it "converts 'true'/'false' strings to booleans" do
|
|
result = tool.coerce_parameters({ flag: true })
|
|
expect(result[:flag]).to be true
|
|
|
|
result = tool.coerce_parameters({ flag: "False" })
|
|
expect(result[:flag]).to be false
|
|
end
|
|
|
|
it "returns nil for invalid boolean strings" do
|
|
result = tool.coerce_parameters({ "flag" => "not a boolean" })
|
|
expect(result["flag"]).to be_nil
|
|
end
|
|
end
|
|
end
|
|
|
|
# Test case 9: Duplicate parameter validation
|
|
describe "duplicate parameter validation" do
|
|
it "rejects tool definitions with duplicate parameter names" do
|
|
param1 =
|
|
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
|
|
name: "query",
|
|
description: "Search query",
|
|
type: :string,
|
|
)
|
|
|
|
param2 =
|
|
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
|
|
name: "query", # Same name as param1
|
|
description: "Another parameter",
|
|
type: :string,
|
|
)
|
|
|
|
expect {
|
|
described_class.new(
|
|
name: "search",
|
|
description: "Search tool",
|
|
parameters: [param1, param2],
|
|
)
|
|
}.to raise_error(ArgumentError, /Duplicate parameter names/)
|
|
end
|
|
end
|
|
|
|
# Test case 10: Serialization to hash
|
|
describe "#to_h" do
|
|
it "serializes the tool to a hash with all properties" do
|
|
param =
|
|
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
|
|
name: "colors",
|
|
description: "List of colors",
|
|
type: :array,
|
|
item_type: :string,
|
|
required: true,
|
|
)
|
|
|
|
tool =
|
|
described_class.new(
|
|
name: "palette",
|
|
description: "Color palette generator",
|
|
parameters: [param],
|
|
)
|
|
|
|
hash = tool.to_h
|
|
|
|
expect(hash[:name]).to eq("palette")
|
|
expect(hash[:description]).to eq("Color palette generator")
|
|
expect(hash[:parameters].size).to eq(1)
|
|
|
|
param_hash = hash[:parameters].first
|
|
expect(param_hash[:name]).to eq("colors")
|
|
expect(param_hash[:type]).to eq(:array)
|
|
expect(param_hash[:item_type]).to eq(:string)
|
|
expect(param_hash[:required]).to eq(true)
|
|
end
|
|
end
|
|
end
|