DEV: use a proper object for tool definition (#1337)

* 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>
This commit is contained in:
Sam 2025-05-15 17:32:39 +10:00 committed by GitHub
parent c34fcc8a95
commit 2c6459429f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 813 additions and 230 deletions

View File

@ -9,33 +9,8 @@ module DiscourseAi
end
def translated_tools
# Transform the raw tools into the required Anthropic Claude API format
raw_tools.map do |t|
properties = {}
required = []
if t[:parameters]
properties = {}
t[:parameters].each do |param|
mapped = { type: param[:type], description: param[:description] }
mapped[:items] = { type: param[:item_type] } if param[:item_type]
mapped[:enum] = param[:enum] if param[:enum]
properties[param[:name]] = mapped
end
required =
t[:parameters].select { |param| param[:required] }.map { |param| param[:name] }
end
{
name: t[:name],
description: t[:description],
input_schema: {
type: "object",
properties: properties,
required: required,
},
}
{ name: t.name, description: t.description, input_schema: t.parameters_json_schema }
end
end

View File

@ -38,27 +38,24 @@ module DiscourseAi
end
def translated_tools
raw_tools.map do |t|
tool = t.dup
raw_tools.map do |tool|
defs = {}
tool[:parameter_definitions] = t[:parameters]
.to_a
.reduce({}) do |memo, p|
name = p[:name]
memo[name] = {
description: p[:description],
type: cohere_type(p[:type], p[:item_type]),
required: p[:required],
}
tool.parameters.each do |p|
name = p.name
defs[name] = {
description: p.description,
type: cohere_type(p.type, p.item_type),
required: p.required,
}
memo[name][:default] = p[:default] if p[:default]
memo
end
#defs[name][:default] = p.default if p.default
end
{
name: tool[:name] == "search" ? "search_local" : tool[:name],
description: tool[:description],
parameter_definitions: tool[:parameter_definitions],
name: tool.name == "search" ? "search_local" : tool.name,
description: tool.description,
parameter_definitions: defs,
}
end
end
@ -72,6 +69,7 @@ module DiscourseAi
attr_reader :raw_tools
def cohere_type(type, item_type)
type = type.to_s
case type
when "string"
"str"

View File

@ -47,22 +47,8 @@ module DiscourseAi
translated_tools =
prompt.tools.map do |t|
tool = t.slice(:name, :description)
if t[:parameters]
tool[:parameters] = t[:parameters].reduce(
{ type: "object", required: [], properties: {} },
) do |memo, p|
name = p[:name]
memo[:required] << name if p[:required]
memo[:properties][name] = p.except(:name, :required, :item_type)
memo[:properties][name][:items] = { type: p[:item_type] } if p[:item_type]
memo
end
end
tool = { name: t.name, description: t.description }
tool[:parameters] = t.parameters_json_schema if t.parameters
tool
end

View File

@ -17,10 +17,10 @@ module DiscourseAi
@raw_tools.map do |tool|
{
toolSpec: {
name: tool[:name],
description: tool[:description],
name: tool.name,
description: tool.description,
inputSchema: {
json: convert_tool_to_input_schema(tool),
json: tool.parameters_json_schema,
},
},
}
@ -51,32 +51,6 @@ module DiscourseAi
},
}
end
private
def convert_tool_to_input_schema(tool)
tool = tool.transform_keys(&:to_sym)
properties = {}
tool[:parameters].each do |param|
schema = {}
type = param[:type]
type = "string" if !%w[string number boolean integer array].include?(type)
schema[:type] = type
if enum = param[:enum]
schema[:enum] = enum
end
schema[:items] = { type: param[:item_type] } if type == "array"
schema[:required] = true if param[:required]
properties[param[:name]] = schema
end
{ type: "object", properties: properties }
end
end
end
end

View File

@ -14,23 +14,15 @@ module DiscourseAi
end
def translated_tools
raw_tools.map do |t|
tool = t.dup
tool[:parameters] = t[:parameters]
.to_a
.reduce({ type: "object", properties: {}, required: [] }) do |memo, p|
name = p[:name]
memo[:required] << name if p[:required]
except = %i[name required item_type]
except << :enum if p[:enum].blank?
memo[:properties][name] = p.except(*except)
memo
end
{ type: "function", function: tool }
raw_tools.map do |tool|
{
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters_json_schema,
},
}
end
end

View File

@ -9,25 +9,15 @@ module DiscourseAi
end
def translated_tools
raw_tools.map do |t|
tool = t.dup
tool[:parameters] = t[:parameters]
.to_a
.reduce({ type: "object", properties: {}, required: [] }) do |memo, p|
name = p[:name]
memo[:required] << name if p[:required]
except = %i[name required item_type]
except << :enum if p[:enum].blank?
memo[:properties][name] = p.except(*except)
memo[:properties][name][:items] = { type: p[:item_type] } if p[:item_type]
memo
end
{ type: "function", function: tool }
raw_tools.map do |tool|
{
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters_json_schema,
},
}
end
end

View File

@ -9,33 +9,37 @@ module DiscourseAi
end
def translated_tools
raw_tools.reduce(+"") do |tools, function|
result = +""
raw_tools.each do |tool|
parameters = +""
if function[:parameters].present?
function[:parameters].each do |parameter|
if tool.parameters.present?
tool.parameters.each do |parameter|
parameters << <<~PARAMETER
<parameter>
<name>#{parameter[:name]}</name>
<type>#{parameter[:type]}</type>
<description>#{parameter[:description]}</description>
<required>#{parameter[:required]}</required>
<name>#{parameter.name}</name>
<type>#{parameter.type}</type>
<description>#{parameter.description}</description>
<required>#{parameter.required}</required>
PARAMETER
if parameter[:enum]
parameters << "<options>#{parameter[:enum].join(",")}</options>\n"
if parameter.item_type
parameters << "<array_item_type>#{parameter.item_type}</array_item_type>\n"
end
parameters << "<options>#{parameter.enum.join(",")}</options>\n" if parameter.enum
parameters << "</parameter>\n"
end
end
tools << <<~TOOLS
result << <<~TOOLS
<tool_description>
<tool_name>#{function[:name]}</tool_name>
<description>#{function[:description]}</description>
<tool_name>#{tool.name}</tool_name>
<description>#{tool.description}</description>
<parameters>
#{parameters}</parameters>
</tool_description>
TOOLS
end
result
end
def instructions
@ -43,8 +47,7 @@ module DiscourseAi
@instructions ||=
begin
has_arrays =
raw_tools.any? { |tool| tool[:parameters]&.any? { |p| p[:type] == "array" } }
has_arrays = raw_tools.any? { |tool| tool.parameters&.any? { |p| p.type == "array" } }
(<<~TEXT).strip
#{tool_preamble(include_array_tip: has_arrays)}

View File

@ -28,7 +28,7 @@ module DiscourseAi
DiscourseAi::Completions::Endpoints::OpenRouter,
]
endpoints << DiscourseAi::Completions::Endpoints::Ollama if Rails.env.development?
endpoints << DiscourseAi::Completions::Endpoints::Ollama if !Rails.env.production?
if Rails.env.test? || Rails.env.development?
endpoints << DiscourseAi::Completions::Endpoints::Fake
@ -166,6 +166,7 @@ module DiscourseAi
xml_tool_processor =
XmlToolProcessor.new(
partial_tool_calls: partial_tool_calls,
tool_definitions: dialect.prompt.tools,
) if xml_tools_enabled? && dialect.prompt.has_tools?
to_strip = xml_tags_to_strip(dialect)

View File

@ -5,8 +5,8 @@ module DiscourseAi
class Prompt
INVALID_TURN = Class.new(StandardError)
attr_reader :messages
attr_accessor :tools, :topic_id, :post_id, :max_pixels, :tool_choice
attr_reader :messages, :tools
attr_accessor :topic_id, :post_id, :max_pixels, :tool_choice
def initialize(
system_message_text = nil,
@ -37,10 +37,25 @@ module DiscourseAi
@messages.each { |message| validate_message(message) }
@messages.each_cons(2) { |last_turn, new_turn| validate_turn(last_turn, new_turn) }
@tools = tools
self.tools = tools
@tool_choice = tool_choice
end
def tools=(tools)
raise ArgumentError, "tools must be an array" if !tools.is_a?(Array) && !tools.nil?
@tools =
tools.map do |tool|
if tool.is_a?(Hash)
ToolDefinition.from_hash(tool)
elsif tool.is_a?(ToolDefinition)
tool
else
raise ArgumentError, "tool must be a hash or a ToolDefinition was #{tool.class}"
end
end
end
# this new api tries to create symmetry between responses and prompts
# this means anything we get back from the model via endpoint can be easily appended
def push_model_response(response)

View File

@ -0,0 +1,252 @@
# frozen_string_literal: true
module DiscourseAi
module Completions
class ToolDefinition
class ParameterDefinition
ALLOWED_TYPES = %i[string boolean integer array number].freeze
ALLOWED_KEYS = %i[name description type required enum item_type].freeze
attr_reader :name, :description, :type, :required, :enum, :item_type
def self.from_hash(hash)
extra_keys = hash.keys - ALLOWED_KEYS
if !extra_keys.empty?
raise ArgumentError, "Unexpected keys in parameter definition: #{extra_keys}"
end
new(
name: hash[:name],
description: hash[:description],
type: hash[:type],
required: hash[:required],
enum: hash[:enum],
item_type: hash[:item_type],
)
end
def initialize(name:, description:, type:, required: false, enum: nil, item_type: nil)
raise ArgumentError, "name must be a string" if !name.is_a?(String) || name.empty?
if !description.is_a?(String) || description.empty?
raise ArgumentError, "description must be a string"
end
type_sym = type.to_sym
if !ALLOWED_TYPES.include?(type_sym)
raise ArgumentError, "type must be one of: #{ALLOWED_TYPES.join(", ")}"
end
# Validate enum if provided
if enum
raise ArgumentError, "enum must be an array" if !enum.is_a?(Array)
# Validate enum entries match the specified type
enum.each do |value|
case type_sym
when :string
if !value.is_a?(String)
raise ArgumentError, "enum values must be strings for type 'string'"
end
when :boolean
if ![true, false].include?(value)
raise ArgumentError, "enum values must be booleans for type 'boolean'"
end
when :integer
if !value.is_a?(Integer)
raise ArgumentError, "enum values must be integers for type 'integer'"
end
when :number
if !value.is_a?(Numeric)
raise ArgumentError, "enum values must be numbers for type 'number'"
end
when :array
if !value.is_a?(Array)
raise ArgumentError, "enum values must be arrays for type 'array'"
end
end
end
end
if item_type && type_sym != :array
raise ArgumentError, "item_type can only be specified for array type"
end
if item_type
if !ALLOWED_TYPES.include?(item_type.to_sym)
raise ArgumentError, "item type must be one of: #{ALLOWED_TYPES.join(", ")}"
end
end
@name = name
@description = description
@type = type_sym
@required = !!required
@enum = enum
@item_type = item_type ? item_type.to_sym : nil
end
def to_h
result = { name: @name, description: @description, type: @type, required: @required }
result[:enum] = @enum if @enum
result[:item_type] = @item_type if @item_type
result
end
end
def parameters_json_schema
properties = {}
required = []
result = { type: "object", properties: properties, required: required }
parameters.each do |param|
name = param.name
required << name if param.required
properties[name] = { type: param.type, description: param.description }
properties[name][:items] = { type: param.item_type } if param.item_type
properties[name][:enum] = param.enum if param.enum
end
result
end
attr_reader :name, :description, :parameters
def self.from_hash(hash)
allowed_keys = %i[name description parameters]
extra_keys = hash.keys - allowed_keys
if !extra_keys.empty?
raise ArgumentError, "Unexpected keys in tool definition: #{extra_keys}"
end
params = hash[:parameters] || []
parameter_objects =
params.map do |param|
if param.is_a?(Hash)
ParameterDefinition.from_hash(param)
else
param
end
end
new(name: hash[:name], description: hash[:description], parameters: parameter_objects)
end
def initialize(name:, description:, parameters: [])
raise ArgumentError, "name must be a string" if !name.is_a?(String) || name.empty?
if !description.is_a?(String) || description.empty?
raise ArgumentError, "description must be a string"
end
raise ArgumentError, "parameters must be an array" if !parameters.is_a?(Array)
# Check for duplicated parameter names
param_names = parameters.map { |p| p.name }
duplicates = param_names.select { |param_name| param_names.count(param_name) > 1 }.uniq
if !duplicates.empty?
raise ArgumentError, "Duplicate parameter names found: #{duplicates.join(", ")}"
end
@name = name
@description = description
@parameters = parameters
end
def to_h
{ name: @name, description: @description, parameters: @parameters.map(&:to_h) }
end
def coerce_parameters(params)
result = {}
return result if !params.is_a?(Hash)
@parameters.each do |param_def|
param_name = param_def.name.to_sym
# Skip if parameter is not provided and not required
next if !params.key?(param_name) && !param_def.required
# Handle required but missing parameters
if !params.key?(param_name) && param_def.required
result[param_name] = nil
next
end
value = params[param_name]
# For array type, handle item coercion
if param_def.type == :array
result[param_name] = coerce_array_value(value, param_def.item_type)
else
result[param_name] = coerce_single_value(value, param_def.type)
end
end
result
end
private
def coerce_array_value(value, item_type)
# Handle non-array input by attempting to parse JSON strings
if !value.is_a?(Array)
if value.is_a?(String)
begin
parsed = JSON.parse(value)
value = parsed.is_a?(Array) ? parsed : nil
rescue JSON::ParserError
return nil
end
else
return nil
end
end
# No item type specified, return the array as is
return value if !item_type
# Coerce each item in the array
value.map { |item| coerce_single_value(item, item_type) }
end
def coerce_single_value(value, type)
result = nil
case type
when :string
result = value.to_s
when :integer
if value.is_a?(Integer)
result = value
elsif value.is_a?(Float)
result = value.to_i
elsif value.is_a?(String) && value.match?(/\A-?\d+\z/)
result = value.to_i
end
when :number
if value.is_a?(Numeric)
result = value.to_f
elsif value.is_a?(String) && value.match?(/\A-?\d+(\.\d+)?\z/)
result = value.to_f
end
when :boolean
if value == true || value == false
result = value
elsif value.is_a?(String)
if value.downcase == "true"
result = true
elsif value.downcase == "false"
result = false
end
end
end
result
end
end
end
end

View File

@ -7,13 +7,14 @@
module DiscourseAi
module Completions
class XmlToolProcessor
def initialize(partial_tool_calls: false)
def initialize(partial_tool_calls: false, tool_definitions: nil)
@buffer = +""
@function_buffer = +""
@should_cancel = false
@in_tool = false
@partial_tool_calls = partial_tool_calls
@partial_tools = [] if @partial_tool_calls
@tool_definitions = tool_definitions
end
def <<(text)
@ -71,7 +72,7 @@ module DiscourseAi
idx = -1
parse_malformed_xml(@function_buffer).map do |tool|
ToolCall.new(
new_tool_call(
id: "tool_#{idx += 1}",
name: tool[:tool_name],
parameters: tool[:parameters],
@ -85,6 +86,13 @@ module DiscourseAi
private
def new_tool_call(id:, name:, parameters:)
if tool_def = @tool_definitions&.find { |d| d.name == name }
parameters = tool_def.coerce_parameters(parameters)
end
ToolCall.new(id:, name:, parameters:)
end
def add_to_function_buffer(text)
@function_buffer << text
detect_partial_tool_calls(@function_buffer, text) if @partial_tool_calls
@ -119,7 +127,7 @@ module DiscourseAi
current_tool = @partial_tools.last
if !current_tool || current_tool.name != match[0].strip
current_tool =
ToolCall.new(
new_tool_call(
id: "tool_#{@partial_tools.length}",
name: match[0].strip,
parameters: params,

View File

@ -99,17 +99,21 @@ RSpec.describe DiscourseAi::Completions::Dialects::ChatGpt do
it "returns a list of available tools" do
open_ai_tool_f = {
function: {
description: context.tools.first[:description],
name: context.tools.first[:name],
description: context.tools.first.description,
name: context.tools.first.name,
parameters: {
properties:
context.tools.first[:parameters].reduce({}) do |memo, p|
memo[p[:name]] = { description: p[:description], type: p[:type] }
context
.tools
.first
.parameters
.reduce({}) do |memo, p|
memo[p.name] = { description: p.description, type: p.type }
memo[p[:name]][:enum] = p[:enum] if p[:enum]
memo[p.name][:enum] = p.enum if p.enum
memo
end,
memo
end,
required: %w[location unit],
type: "object",
},

View File

@ -115,6 +115,6 @@ class DialectContext
},
],
},
]
].map { |tool| DiscourseAi::Completions::ToolDefinition.from_hash(tool) }
end
end

View File

@ -108,11 +108,11 @@ RSpec.describe DiscourseAi::Completions::Dialects::Gemini do
required: %w[location unit],
properties: {
"location" => {
type: "string",
type: :string,
description: "the city name",
},
"unit" => {
type: "string",
type: :string,
description: "the unit of measurement celcius c or fahrenheit f",
enum: %w[c f],
},
@ -121,7 +121,6 @@ RSpec.describe DiscourseAi::Completions::Dialects::Gemini do
},
],
}
expect(context.dialect_tools).to contain_exactly(gemini_tools)
end
end

View File

@ -84,29 +84,30 @@ RSpec.describe DiscourseAi::Completions::Dialects::Nova do
dialect = nova_dialect_klass.new(prompt, llm_model)
translated = dialect.translate
expect(translated.tool_config).to eq(
{
tools: [
{
toolSpec: {
name: "get_weather",
description: "Get the weather in a city",
inputSchema: {
json: {
type: "object",
properties: {
"location" => {
type: "string",
required: true,
},
expected = {
tools: [
{
toolSpec: {
name: "get_weather",
description: "Get the weather in a city",
inputSchema: {
json: {
type: "object",
properties: {
"location" => {
type: :string,
description: "the city name",
},
},
required: ["location"],
},
},
},
],
},
)
},
],
}
expect(translated.tool_config).to eq(expected)
end
end

View File

@ -5,6 +5,7 @@ require_relative "dialect_context"
RSpec.describe DiscourseAi::Completions::Dialects::Ollama do
fab!(:model) { Fabricate(:ollama_model) }
let(:context) { DialectContext.new(described_class, model) }
let(:dialect_class) { DiscourseAi::Completions::Dialects::Dialect.dialect_for(model) }
describe "#translate" do
context "when native tool support is enabled" do
@ -59,35 +60,49 @@ RSpec.describe DiscourseAi::Completions::Dialects::Ollama do
describe "#tools" do
context "when native tools are enabled" do
it "returns the translated tools from the OllamaTools class" do
tool = instance_double(DiscourseAi::Completions::Dialects::OllamaTools)
model.update!(provider_params: { enable_native_tool: true })
allow(model).to receive(:lookup_custom_param).with("enable_native_tool").and_return(true)
allow(tool).to receive(:translated_tools)
allow(DiscourseAi::Completions::Dialects::OllamaTools).to receive(:new).and_return(tool)
tool = { name: "noop", description: "do nothing" }
messages = [
{ type: :user, content: "echo away" },
{ type: :tool_call, content: "{}", name: "noop" },
{ type: :tool, content: "{}", name: "noop" },
]
prompt = DiscourseAi::Completions::Prompt.new("a bot", tools: [tool], messages: messages)
dialect = dialect_class.new(prompt, model)
context.dialect_tools
expect(DiscourseAi::Completions::Dialects::OllamaTools).to have_received(:new).with(
context.prompt.tools,
)
expect(tool).to have_received(:translated_tools)
expected = [
{ role: "system", content: "a bot" },
{ role: "user", content: "echo away" },
{
role: "assistant",
content: nil,
tool_calls: [{ type: "function", function: { name: "noop" } }],
},
{ role: "tool", content: "{}", name: "noop" },
]
expect(dialect.translate).to eq(expected)
end
end
context "when native tools are disabled" do
it "returns the translated tools from the XmlTools class" do
tool = instance_double(DiscourseAi::Completions::Dialects::XmlTools)
model.update!(provider_params: { enable_native_tool: false })
allow(model).to receive(:lookup_custom_param).with("enable_native_tool").and_return(false)
allow(tool).to receive(:translated_tools)
allow(DiscourseAi::Completions::Dialects::XmlTools).to receive(:new).and_return(tool)
tool = { name: "noop", description: "do nothing" }
messages = [
{ type: :user, content: "echo away" },
{ type: :tool_call, content: "{}", name: "noop" },
{ type: :tool, content: "{}", name: "noop" },
]
prompt = DiscourseAi::Completions::Prompt.new("a bot", tools: [tool], messages: messages)
dialect = dialect_class.new(prompt, model)
context.dialect_tools
expected = %w[system user assistant user]
roles = dialect.translate.map { |x| x[:role] }
expect(DiscourseAi::Completions::Dialects::XmlTools).to have_received(:new).with(
context.prompt.tools,
)
expect(tool).to have_received(:translated_tools)
# notice, no tool role
expect(roles).to eq(expected)
end
end
end

View File

@ -5,34 +5,33 @@ require_relative "dialect_context"
RSpec.describe DiscourseAi::Completions::Dialects::OllamaTools do
describe "#translated_tools" do
it "translates a tool from our generic format to the Ollama format" do
tools = [
{
name: "github_file_content",
description: "Retrieves the content of specified GitHub files",
parameters: [
{
name: "repo_name",
description: "The name of the GitHub repository (e.g., 'discourse/discourse')",
type: "string",
required: true,
},
{
name: "file_paths",
description: "The paths of the files to retrieve within the repository",
type: "array",
item_type: "string",
required: true,
},
{
name: "branch",
description: "The branch or commit SHA to retrieve the files from (default: 'main')",
type: "string",
required: false,
},
],
},
]
tool = {
name: "github_file_content",
description: "Retrieves the content of specified GitHub files",
parameters: [
{
name: "repo_name",
description: "The name of the GitHub repository (e.g., 'discourse/discourse')",
type: "string",
required: true,
},
{
name: "file_paths",
description: "The paths of the files to retrieve within the repository",
type: "array",
item_type: "string",
required: true,
},
{
name: "branch",
description: "The branch or commit SHA to retrieve the files from (default: 'main')",
type: "string",
required: false,
},
],
}
tools = [DiscourseAi::Completions::ToolDefinition.from_hash(tool)]
ollama_tools = described_class.new(tools)
translated_tools = ollama_tools.translated_tools
@ -49,16 +48,19 @@ RSpec.describe DiscourseAi::Completions::Dialects::OllamaTools do
properties: {
"repo_name" => {
description: "The name of the GitHub repository (e.g., 'discourse/discourse')",
type: "string",
type: :string,
},
"file_paths" => {
description: "The paths of the files to retrieve within the repository",
type: "array",
type: :array,
items: {
type: :string,
},
},
"branch" => {
description:
"The branch or commit SHA to retrieve the files from (default: 'main')",
type: "string",
type: :string,
},
},
required: %w[repo_name file_paths],

View File

@ -196,10 +196,11 @@ RSpec.describe DiscourseAi::Completions::Endpoints::AwsBedrock do
"inputSchema" => {
"json" => {
"type" => "object",
"required" => ["timezone"],
"properties" => {
"timezone" => {
"type" => "string",
"required" => true,
"description" => "The timezone",
},
},
},
@ -268,9 +269,10 @@ RSpec.describe DiscourseAi::Completions::Endpoints::AwsBedrock do
properties: {
timezone: {
type: "string",
required: true,
description: "The timezone",
},
},
required: ["timezone"],
},
},
},

View File

@ -542,11 +542,24 @@ RSpec.describe DiscourseAi::Completions::Endpoints::OpenAi do
<parameters>
<location>Sydney</location>
<unit>c</unit>
<is_it_hot>true</is_it_hot>
</parameters>
</invoke>
</function_calls>
XML
let(:weather_tool) do
{
name: "get_weather",
description: "get weather",
parameters: [
{ name: "location", type: "string", description: "location", required: true },
{ name: "unit", type: "string", description: "unit", required: true, enum: %w[c f] },
{ name: "is_it_hot", type: "boolean", description: "is it hot" },
],
}
end
it "parses XML tool calls" do
response = {
id: "chatcmpl-6sZfAb30Rnv9Q7ufzFwvQsMpjZh8S",
@ -574,7 +587,7 @@ RSpec.describe DiscourseAi::Completions::Endpoints::OpenAi do
body = nil
open_ai_mock.stub_raw(response, body_blk: proc { |inner_body| body = inner_body })
dialect = compliance.dialect(prompt: compliance.generic_prompt(tools: tools))
dialect = compliance.dialect(prompt: compliance.generic_prompt(tools: [weather_tool]))
tool_call = endpoint.perform_completion!(dialect, user)
body_parsed = JSON.parse(body, symbolize_names: true)
@ -583,7 +596,7 @@ RSpec.describe DiscourseAi::Completions::Endpoints::OpenAi do
expect(body_parsed[:messages][0][:content]).to include("<function_calls>")
expect(tool_call.name).to eq("get_weather")
expect(tool_call.parameters).to eq({ location: "Sydney", unit: "c" })
expect(tool_call.parameters).to eq({ location: "Sydney", unit: "c", is_it_hot: true })
end
end

View File

@ -0,0 +1,338 @@
# 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

View File

@ -93,10 +93,24 @@ RSpec.describe DiscourseAi::Completions::XmlToolProcessor do
<parameters>
<hello>world</hello>
<test>value</test>
<bool>true</bool>
</parameters>
</invoke>
XML
tool_definition =
DiscourseAi::Completions::ToolDefinition.from_hash(
name: "hello",
description: "hello world",
parameters: [
{ name: "hello", type: "string", description: "hello" },
{ name: "test", type: "string", description: "test" },
{ name: "bool", type: "boolean", description: "bool" },
],
)
processor = DiscourseAi::Completions::XmlToolProcessor.new(tool_definitions: [tool_definition])
result = []
result << (processor << "hello")
result << (processor << xml)
@ -109,6 +123,7 @@ RSpec.describe DiscourseAi::Completions::XmlToolProcessor do
parameters: {
hello: "world",
test: "value",
bool: true,
},
)
expect(result).to eq([["hello"], [" world"], [tool_call]])

View File

@ -69,11 +69,11 @@ RSpec.describe DiscourseAi::Personas::Persona do
tools = rendered.tools
expect(tools.find { |t| t[:name] == "search" }).to be_present
expect(tools.find { |t| t[:name] == "tags" }).to be_present
expect(tools.find { |t| t.name == "search" }).to be_present
expect(tools.find { |t| t.name == "tags" }).to be_present
# needs to be configured so it is not available
expect(tools.find { |t| t[:name] == "image" }).to be_nil
expect(tools.find { |t| t.name == "image" }).to be_nil
end
it "can parse string that are wrapped in quotes" do