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
-
.dig(obj, path) ⇒ Object
Walks a dotted path on a plain object: nil when a segment is absent or the intermediate is not a Hash.
-
.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).
-
.violation_output(spec, raw) ⇒ Object
The evidence RESULT contract: [{id, line]} .
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 |
.dig(obj, path) ⇒ Object
Walks a dotted path on a plain object: nil when a segment is absent or the intermediate is not a Hash. Shared by the evidence output check and the Processor's extraction.
137 138 139 140 141 142 143 |
# File 'lib/insika/schema_guard.rb', line 137 def dig(obj, path) path.to_s.split(".").reduce(obj) do |cur, seg| return nil unless cur.is_a?(Hash) && cur.key?(seg) cur[seg] end end |
.kind(value) ⇒ Object
Name the shape the way a model reads it, not the way Ruby does.
170 171 172 173 174 175 176 177 178 179 180 |
# File 'lib/insika/schema_guard.rb', line 170 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 |
.violation_output(spec, raw) ⇒ Object
The evidence RESULT contract: [{id, line]} .
-> nil | String. Same idiom as violation: nil = fine, one message = what
is wrong. A malformed evidence result is a correctable TOOL answer — the
envelope returns it to the model as {error:}, exactly like a malformed
call is today. A raw body that is NOT an object (a bare JSON array from a
search, a string, nil) is a violation — never a silent {items: []} that
the model reads as "no products".
152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 |
# File 'lib/insika/schema_guard.rb', line 152 def violation_output(spec, raw) return nil if spec.nil? return "evidence: result must be an object" unless raw.is_a?(Hash) items = dig(raw, spec.items_path) return "evidence: items is missing" if items.nil? return "evidence: items must be a list" unless items.is_a?(Array) items.each_with_index do |item, i| ok = item.is_a?(Hash) && Coercion.present?(item["id"] || item[:id]) && (item["line"] || item[:line]).is_a?(String) return "evidence: items[#{i}] must be {id, line}" unless ok end nil end |