From 1973c557e54aa9bec97e78e0342ed9baecbd34d0 Mon Sep 17 00:00:00 2001 From: Brandon Date: Thu, 13 Aug 2026 17:59:59 -0700 Subject: [PATCH] fix(ai): drop empty data-driven enums from assistant function schemas (#3016) * fix(ai): drop empty data-driven enums from assistant function schemas Enum values in tool schemas are built from family data (account names, categories, merchants, tags, tickers). A family with none of these gets enum: [], which is invalid JSON Schema. OpenAI tolerates it, but strict OpenAI-compatible providers reject the entire request, breaking chat for fresh families until they create a tag or merchant. Prune empty enums in build_schema, falling back to a plain string. One choke point covers every function and both consumers: chat tool definitions for all providers, and the /mcp endpoint's tools/list. * fix(ai): address review feedback on enum pruning Stop recursion at populated enum values: enum members are literal values, not subschemas, so a literal like enum: [{ enum: [] }] must be preserved verbatim rather than rewritten. Also cover PREVIEW_FUNCTION_CLASSES in the registry regression test by enabling the preview preference on the test user, with a guard assertion so the test fails if preview functions ever silently drop out. --- app/models/assistant/function.rb | 38 ++++++++- test/models/assistant/function_test.rb | 108 +++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 test/models/assistant/function_test.rb diff --git a/app/models/assistant/function.rb b/app/models/assistant/function.rb index 99e4df300..c2d6eac60 100644 --- a/app/models/assistant/function.rb +++ b/app/models/assistant/function.rb @@ -49,12 +49,48 @@ class Assistant::Function def build_schema(properties: {}, required: []) { type: "object", - properties: properties, + properties: prune_empty_enums(properties), required: required, additionalProperties: false } end + # Enum values are built from user data (account names, tags, merchants, etc.) + # and can be empty for a new family. An empty enum is invalid JSON Schema and + # strict providers (e.g. xAI) reject the entire request, so drop the + # constraint and fall back to a plain string. + def prune_empty_enums(node) + case node + when Hash + pruned = {} + dropped_enum = false + + node.each do |key, value| + # Enum members are literal values, not subschemas; keep them verbatim + if key.to_sym == :enum && value.is_a?(Array) + if value.empty? + dropped_enum = true + else + pruned[key] = value + end + next + end + + pruned[key] = prune_empty_enums(value) + end + + if dropped_enum && !pruned.key?(:type) && !pruned.key?("type") + pruned[:type] = "string" + end + + pruned + when Array + node.map { |item| prune_empty_enums(item) } + else + node + end + end + def family_account_names @family_account_names ||= user.accessible_accounts.visible.pluck(:name) end diff --git a/test/models/assistant/function_test.rb b/test/models/assistant/function_test.rb new file mode 100644 index 000000000..78ddd59d2 --- /dev/null +++ b/test/models/assistant/function_test.rb @@ -0,0 +1,108 @@ +require "test_helper" + +class Assistant::FunctionTest < ActiveSupport::TestCase + class EmptyEnumFunction < Assistant::Function + class << self + def name + "empty_enum_function" + end + + def description + "Test function with data-driven enums" + end + end + + def call(params = {}) + {} + end + + def params_schema + build_schema( + required: [ "name" ], + properties: { + name: { + type: "string", + description: "Property-level enum built from empty user data", + enum: [] + }, + accounts: { + type: "array", + description: "Items-level enum built from empty user data", + items: { enum: [] }, + minItems: 1, + uniqueItems: true + }, + order: { + enum: [ "asc", "desc" ], + description: "Static enum that must be preserved" + } + } + ) + end + end + + setup do + @function = EmptyEnumFunction.new(users(:family_admin)) + end + + test "drops empty enums so strict providers accept the schema" do + properties = @function.to_definition[:params_schema][:properties] + + refute properties[:name].key?(:enum) + assert_equal "string", properties[:name][:type] + + refute properties[:accounts][:items].key?(:enum) + assert_equal "string", properties[:accounts][:items][:type] + assert_equal 1, properties[:accounts][:minItems] + end + + test "preserves populated enums and surrounding schema" do + schema = @function.to_definition[:params_schema] + + assert_equal [ "asc", "desc" ], schema[:properties][:order][:enum] + assert_equal [ "name" ], schema[:required] + assert_equal "object", schema[:type] + end + + test "keeps populated enum values verbatim, including literal objects and arrays" do + schema = { + status: { enum: [ { enum: [] }, [ "nested" ] ] }, + order: { enum: [ "asc" ] } + } + + pruned = @function.send(:prune_empty_enums, schema) + + assert_equal [ { enum: [] }, [ "nested" ] ], pruned[:status][:enum] + assert_equal [ "asc" ], pruned[:order][:enum] + end + + test "no registered function emits an empty enum" do + user = users(:family_admin) + user.update!(preferences: (user.preferences || {}).merge("preview_features_enabled" => true)) + + function_classes = Assistant.function_classes(user) + assert_empty Assistant::PREVIEW_FUNCTION_CLASSES - function_classes, + "expected preview functions to be included in the assertion" + + function_classes.each do |function_class| + definition = function_class.new(user).to_definition + assert_no_empty_enums definition[:params_schema], function_class.name + end + end + + private + def assert_no_empty_enums(node, function_name, path = "params_schema") + case node + when Hash + node.each do |key, value| + refute key.to_sym == :enum && value == [], + "#{function_name} emits an empty enum at #{path}.#{key}, which is invalid JSON Schema" + assert_no_empty_enums(value, function_name, "#{path}.#{key}") + end + when Array + node.each_with_index do |value, index| + assert_no_empty_enums(value, function_name, "#{path}[#{index}]") + end + end + end +end