# frozen_string_literal: true module Dispatch module Adapter class Claude < Base module RequestBuilder # Converts an Array (or plain Hashes) # into the Anthropic tools wire format: # [{name:, description:, input_schema:, strict?:}, …] # # Key transformations applied here: # - Tool names are prefixed with "proxy_" when OAuth is active. # - Unsupported JSON-Schema fields are removed (maxItems on objects, # patternProperties on objects, minItems on objects, and minItems # on arrays when the value is not 0 or 1). # - additionalProperties: false is injected on every object node. # - Tools whose base name is in STRICT_ALLOWLIST get strict: true when # the schema fits within the strict-mode parameter budgets. # - disable_strict: true or ENV["CLAUDE_NO_STRICT"] suppresses all # strict: true annotations. module Tools UNSUPPORTED_FIELDS = %w[maxItems patternProperties].freeze STRICT_ALLOWLIST = %w[bash python edit find].freeze MAX_STRICT_TOOLS = 20 MAX_STRICT_OPTIONAL_PARAMS = 24 MAX_STRICT_UNION_PARAMS = 16 module_function # Convert tool definitions to the Anthropic wire format. # # @param tools [Array] # @param is_oauth [Boolean] # @param disable_strict [Boolean] # @return [Array] def build(tools, is_oauth: false, disable_strict: false) return [] if tools.nil? || tools.empty? tools.map do |tool| convert_tool(tool, is_oauth: is_oauth, disable_strict: disable_strict) end end def convert_tool(tool, is_oauth:, disable_strict:) orig_name = extract_name(tool) description = extract_description(tool) parameters = extract_parameters(tool) # Apply proxy_ prefix for OAuth mode wire_name = is_oauth ? Cloaking.apply_prefix(orig_name) : orig_name # Deep-clone and sanitise the schema (always applied) raw_schema = parameters || { "type" => "object", "properties" => {} } sanitised = sanitise_schema(deep_clone(raw_schema)) wire = { name: wire_name, description: description, input_schema: sanitised } # Optionally upgrade to strict mode if eligible_for_strict?(orig_name, disable_strict) budget = count_budget(sanitised) if budget[:optional] <= MAX_STRICT_OPTIONAL_PARAMS && budget[:union] <= MAX_STRICT_UNION_PARAMS normalised = normalise_for_strict(deep_clone(sanitised)) if normalised wire[:input_schema] = normalised wire[:strict] = true end end end wire end # --------------------------------------------------------------------------- # Field extraction helpers # --------------------------------------------------------------------------- def extract_name(tool) if tool.respond_to?(:name) tool.name.to_s else (tool[:name] || tool["name"]).to_s end end def extract_description(tool) if tool.respond_to?(:description) tool.description else tool[:description] || tool["description"] end end def extract_parameters(tool) if tool.respond_to?(:parameters) tool.parameters else tool[:parameters] || tool["parameters"] end end # --------------------------------------------------------------------------- # Deep clone # --------------------------------------------------------------------------- def deep_clone(obj) case obj when Hash then obj.each_with_object({}) { |(k, v), h| h[k] = deep_clone(v) } when Array then obj.map { |v| deep_clone(v) } else obj end end # --------------------------------------------------------------------------- # Schema sanitisation (applied to ALL tools) # --------------------------------------------------------------------------- # Walk the schema recursively. On every object node: # - Strip UNSUPPORTED_FIELDS and minItems # - Set additionalProperties: false # On every array node: # - Strip minItems when value is not 0 or 1 # Normalise all hash keys to strings. def sanitise_schema(schema) return schema unless schema.is_a?(Hash) schema = schema.transform_keys(&:to_s) # Recurse first, so children are clean before we inspect type schema = recurse_sub_schemas(schema) type = schema["type"] if type == "object" UNSUPPORTED_FIELDS.each { |f| schema.delete(f) } schema.delete("minItems") schema["additionalProperties"] = false unless schema.key?("additionalProperties") end schema.delete("minItems") if type == "array" && schema.key?("minItems") && ![0, 1].include?(schema["minItems"]) schema end # Recurse into all sub-schema positions. def recurse_sub_schemas(schema) if schema["properties"].is_a?(Hash) schema["properties"] = schema["properties"].each_with_object({}) do |(k, v), h| h[k.to_s] = sanitise_schema(v) end end %w[items not additionalProperties].each do |key| schema[key] = sanitise_schema(schema[key]) if schema[key].is_a?(Hash) end schema["items"] = schema["items"].map { |v| sanitise_schema(v) } if schema["items"].is_a?(Array) %w[anyOf oneOf allOf].each do |key| next unless schema[key].is_a?(Array) schema[key] = schema[key].map { |v| sanitise_schema(v) } end schema end # --------------------------------------------------------------------------- # Strict-mode eligibility # --------------------------------------------------------------------------- # Returns true when this tool is a candidate for strict: true. def eligible_for_strict?(orig_name, disable_strict) return false if disable_strict return false if ENV["CLAUDE_NO_STRICT"] base = Cloaking.strip_prefix(orig_name) STRICT_ALLOWLIST.include?(base.downcase) end # --------------------------------------------------------------------------- # Budget counting (on the sanitised schema, before normalisation) # --------------------------------------------------------------------------- # Count: # :optional — number of properties NOT in required across all object nodes # :union — number of anyOf/oneOf/allOf branch-schemas across all nodes def count_budget(schema, acc = { optional: 0, union: 0 }) return acc unless schema.is_a?(Hash) schema = schema.transform_keys(&:to_s) # Count union variants %w[anyOf oneOf allOf].each do |key| next unless schema[key].is_a?(Array) acc[:union] += schema[key].length schema[key].each { |sub| count_budget(sub, acc) } end # Count optional properties in object nodes if schema["type"] == "object" props = (schema["properties"] || {}).keys.map(&:to_s) required = Array(schema["required"] || []).map(&:to_s) acc[:optional] += (props - required).length (schema["properties"] || {}).each_value { |v| count_budget(v, acc) } end # Recurse into array items items = schema["items"] if items.is_a?(Hash) count_budget(items, acc) elsif items.is_a?(Array) items.each { |v| count_budget(v, acc) } end # Recurse into not / additionalProperties (if schema) count_budget(schema["not"], acc) if schema["not"].is_a?(Hash) acc end # --------------------------------------------------------------------------- # Strict-mode normalisation # --------------------------------------------------------------------------- # Walk every object node and: # 1. Move all properties into required # 2. Wrap optional properties in anyOf: [{...}, {type: "null"}] # 3. Set additionalProperties: false # # Returns the mutated (deep-cloned) schema, or nil if normalisation fails. def normalise_for_strict(schema) return schema unless schema.is_a?(Hash) schema = schema.transform_keys(&:to_s) if schema["type"] == "object" props = schema["properties"] || {} required = Array(schema["required"] || []).map(&:to_s) all_keys = props.keys.map(&:to_s) optional = all_keys - required # Make optional properties nullable optional.each do |prop| props[prop] = make_nullable(props[prop]) if props[prop].is_a?(Hash) end # Recursively normalise nested object properties props.each do |prop, sub| next unless sub.is_a?(Hash) normalised_sub = normalise_for_strict(sub) return nil if normalised_sub.nil? props[prop] = normalised_sub end schema["required"] = all_keys schema["properties"] = props schema["additionalProperties"] = false end # Recurse into array items items = schema["items"] if items.is_a?(Hash) normalised_items = normalise_for_strict(items) return nil if normalised_items.nil? schema["items"] = normalised_items elsif items.is_a?(Array) normalised_items = items.map { |v| normalise_for_strict(v) } return nil if normalised_items.any?(&:nil?) schema["items"] = normalised_items end # Recurse into anyOf / oneOf / allOf branches %w[anyOf oneOf allOf].each do |key| next unless schema[key].is_a?(Array) normalised = schema[key].map { |v| normalise_for_strict(v) } return nil if normalised.any?(&:nil?) schema[key] = normalised end schema end # Wrap a schema so it also allows null. # # If the schema already uses anyOf, append {type: "null"} (once). # Otherwise convert to anyOf: [{original}, {type: "null"}]. def make_nullable(schema) return schema unless schema.is_a?(Hash) null_schema = { "type" => "null" } if schema.key?("anyOf") branches = schema["anyOf"] return schema if branches.is_a?(Array) && branches.any? { |s| s.is_a?(Hash) && s["type"] == "null" } schema["anyOf"] = Array(branches) + [null_schema] return schema end # Simple schema with explicit type (or no type) existing_type = schema["type"] return schema if existing_type == "null" return schema if existing_type.is_a?(Array) && existing_type.include?("null") { "anyOf" => [schema, null_schema] } end end end end end end