Module: Insika::SchemaGuard
- Defined in:
- lib/insika/schema_guard.rb
Overview
Checks a tool call's ARGUMENTS against the tool's JSON Schema, at call time.
violation returns nil (fine) or ONE message describing what is wrong —
the same idiom as EgressGuard, and consumed the same way: DataDefinedTool turns
it into { error: … } for the model, so a malformed call is a correctable
answer instead of a request that goes out shaped wrong.
Why this exists: the schema declares the contract, but nothing used to hold the
model to it. A call carrying ["arroz"] where the schema says
[{query, filters}] was interpolated into the body as-is, the backend answered
200, and the wrong results came back with no error anywhere. Validating here
closes that loop and names the fix in the message the model reads next.
Scope: the safe subset ToolDefinition already validates
(object/array/string/number/integer/boolean + enum + minItems/maxItems). Only what
the schema DECLARES is checked; undeclared keys pass (providers add nothing, and
additionalProperties is a tool author's business, not ours).
NEVER coerces. The value the model sent is what reaches the request — the guard only decides whether the call may proceed, so turning it on cannot change the bytes of a call that was already correct.
Constant Summary collapse
- NUMERIC_RE =
A scalar the schema calls a number/integer/boolean may arrive as its string form ("2", "true") — providers do that, it is lossless, and rejecting it would break working tools for no gain. Structure (object/array) is NEVER lenient.
/\A-?\d+(?:\.\d+)?\z/- INTEGER_RE =
/\A-?\d+\z/- BOOLEAN_STRINGS =
%w[true false].freeze
- MAX_REPORTED =
5
Class Method Summary collapse
-
.check(value, schema, path) ⇒ Object
-> [String] problems found at/below
path. - .check_array(value, schema, path) ⇒ Object
- .check_object(value, schema, path) ⇒ Object
- .check_scalar(value, schema, path) ⇒ Object
-
.kind(value) ⇒ Object
Name the shape the way a model reads it, not the way Ruby does.
-
.missing_top_level(schema, values) ⇒ Object
Top-level
requireduses PRESENCE (an empty string is missing), because these values feed{{placeholder}}interpolation — an empty one produces a silently broken URL/body. - .scalar_ok?(value, type) ⇒ Boolean
-
.size_problems(value, schema, path) ⇒ Object
minItems/maxItems are the only cardinality the authors actually write (a search that takes "1 or more pairs"), and an empty list is exactly the call that reads as success and returns nothing.
-
.violation(schema, args) ⇒ Object
schema: canonical JSON Schema (ToolDefinition#parameters).
Class Method Details
.check(value, schema, path) ⇒ Object
-> [String] problems found at/below path.
66 67 68 69 70 71 72 73 74 |
# File 'lib/insika/schema_guard.rb', line 66 def check(value, schema, path) return [] unless schema.is_a?(Hash) case schema["type"].to_s when "object" then check_object(value, schema, path) when "array" then check_array(value, schema, path) else check_scalar(value, schema, path) end end |
.check_array(value, schema, path) ⇒ Object
92 93 94 95 96 97 |
# File 'lib/insika/schema_guard.rb', line 92 def check_array(value, schema, path) return ["#{path}: expected a list, got #{kind(value)}"] unless value.is_a?(Array) problems = size_problems(value, schema, path) problems + value.each_with_index.flat_map { |item, i| check(item, schema["items"], "#{path}[#{i}]") } end |
.check_object(value, schema, path) ⇒ Object
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/insika/schema_guard.rb', line 76 def check_object(value, schema, path) return ["#{path}: expected an object, got #{kind(value)}"] unless value.is_a?(Hash) props = schema["properties"] || {} missing = Array(schema["required"]).map(&:to_s).reject { |k| value.key?(k) } problems = missing.map { |k| "#{path}.#{k}: missing (required)" } props.each do |pname, pschema| child = value[pname.to_s] next if child.nil? problems.concat(check(child, pschema, "#{path}.#{pname}")) end problems end |
.check_scalar(value, schema, path) ⇒ Object
111 112 113 114 115 116 117 118 119 120 |
# File 'lib/insika/schema_guard.rb', line 111 def check_scalar(value, schema, path) type = schema["type"].to_s return ["#{path}: expected #{type}, got #{kind(value)}"] unless scalar_ok?(value, type) enum = schema["enum"] return [] unless enum.is_a?(Array) && !enum.empty? return [] if enum.map(&:to_s).include?(value.to_s) ["#{path}: #{value.to_s.inspect} is not one of #{enum.map(&:to_s).join('/')}"] end |
.kind(value) ⇒ Object
Name the shape the way a model reads it, not the way Ruby does.
135 136 137 138 139 140 141 142 143 144 145 |
# File 'lib/insika/schema_guard.rb', line 135 def kind(value) case value when Hash then "an object" when Array then "a list" when String then "a string" when Numeric then "a number" when true, false then "a boolean" when nil then "nothing" else value.class.name.downcase end end |
.missing_top_level(schema, values) ⇒ Object
Top-level required uses PRESENCE (an empty string is missing), because these
values feed {{placeholder}} interpolation — an empty one produces a silently
broken URL/body. Nested required uses JSON Schema semantics (key present),
where "" can be a legitimate value.
61 62 63 |
# File 'lib/insika/schema_guard.rb', line 61 def missing_top_level(schema, values) Array(schema["required"]).map(&:to_s).reject { |n| Insika::Coercion.present?(values[n]) } end |
.scalar_ok?(value, type) ⇒ Boolean
122 123 124 125 126 127 128 129 130 131 132 |
# File 'lib/insika/schema_guard.rb', line 122 def scalar_ok?(value, type) return false if value.is_a?(Hash) || value.is_a?(Array) case type when "string" then true # any scalar stringifies losslessly when "number" then value.is_a?(Numeric) || NUMERIC_RE.match?(value.to_s) when "integer" then value.is_a?(Integer) || INTEGER_RE.match?(value.to_s) when "boolean" then [true, false].include?(value) || BOOLEAN_STRINGS.include?(value.to_s) else true # unknown type: not ours to police end end |
.size_problems(value, schema, path) ⇒ Object
minItems/maxItems are the only cardinality the authors actually write (a search that takes "1 or more pairs"), and an empty list is exactly the call that reads as success and returns nothing.
102 103 104 105 106 107 108 109 |
# File 'lib/insika/schema_guard.rb', line 102 def size_problems(value, schema, path) min = schema["minItems"] max = schema["maxItems"] problems = [] problems << "#{path}: needs at least #{min} item(s), got #{value.length}" if min.is_a?(Numeric) && value.length < min problems << "#{path}: accepts at most #{max} item(s), got #{value.length}" if max.is_a?(Numeric) && value.length > max problems end |
.violation(schema, args) ⇒ Object
schema: canonical JSON Schema (ToolDefinition#parameters). args: the model's kwargs (symbol keys). -> nil | String.
37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
# File 'lib/insika/schema_guard.rb', line 37 def violation(schema, args) return nil unless schema.is_a?(Hash) values = Insika::Coercion.deep_stringify(args || {}) missing = missing_top_level(schema, values) return "missing required parameter(s): #{missing.join(', ')}" unless missing.empty? problems = [] (schema["properties"] || {}).each do |pname, pschema| value = values[pname.to_s] next if value.nil? problems.concat(check(value, pschema, pname.to_s)) break if problems.length >= MAX_REPORTED end return nil if problems.empty? "invalid arguments: #{problems.first(MAX_REPORTED).join('; ')}" end |