mistri 0.0.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +215 -0
  3. data/README.md +367 -3
  4. data/lib/generators/mistri/install/install_generator.rb +54 -0
  5. data/lib/generators/mistri/install/templates/migration.rb.tt +14 -0
  6. data/lib/generators/mistri/install/templates/model.rb.tt +4 -0
  7. data/lib/generators/mistri/mcp/mcp_generator.rb +57 -0
  8. data/lib/generators/mistri/mcp/templates/migration.rb.tt +27 -0
  9. data/lib/generators/mistri/mcp/templates/model.rb.tt +63 -0
  10. data/lib/mistri/abort_signal.rb +63 -0
  11. data/lib/mistri/agent.rb +389 -0
  12. data/lib/mistri/budget.rb +29 -0
  13. data/lib/mistri/compaction.rb +78 -0
  14. data/lib/mistri/compactor.rb +182 -0
  15. data/lib/mistri/content.rb +89 -0
  16. data/lib/mistri/edit.rb +238 -0
  17. data/lib/mistri/errors.rb +94 -0
  18. data/lib/mistri/event.rb +54 -0
  19. data/lib/mistri/mcp/client.rb +156 -0
  20. data/lib/mistri/mcp/oauth.rb +286 -0
  21. data/lib/mistri/mcp/wires.rb +164 -0
  22. data/lib/mistri/mcp.rb +96 -0
  23. data/lib/mistri/memory.rb +26 -0
  24. data/lib/mistri/message.rb +90 -0
  25. data/lib/mistri/models.rb +43 -0
  26. data/lib/mistri/partial_json.rb +210 -0
  27. data/lib/mistri/providers/anthropic/assembler.rb +205 -0
  28. data/lib/mistri/providers/anthropic/serializer.rb +106 -0
  29. data/lib/mistri/providers/anthropic.rb +106 -0
  30. data/lib/mistri/providers/fake.rb +109 -0
  31. data/lib/mistri/providers/gemini/assembler.rb +163 -0
  32. data/lib/mistri/providers/gemini/serializer.rb +109 -0
  33. data/lib/mistri/providers/gemini.rb +73 -0
  34. data/lib/mistri/providers/openai/assembler.rb +205 -0
  35. data/lib/mistri/providers/openai/serializer.rb +104 -0
  36. data/lib/mistri/providers/openai.rb +72 -0
  37. data/lib/mistri/reminder.rb +36 -0
  38. data/lib/mistri/result.rb +32 -0
  39. data/lib/mistri/retry_policy.rb +47 -0
  40. data/lib/mistri/schema.rb +162 -0
  41. data/lib/mistri/session.rb +124 -0
  42. data/lib/mistri/sinks/action_cable.rb +30 -0
  43. data/lib/mistri/sinks/coalesced.rb +61 -0
  44. data/lib/mistri/sinks/sse.rb +26 -0
  45. data/lib/mistri/skill.rb +15 -0
  46. data/lib/mistri/skills.rb +81 -0
  47. data/lib/mistri/sse.rb +50 -0
  48. data/lib/mistri/stop_reason.rb +25 -0
  49. data/lib/mistri/stores/active_record.rb +47 -0
  50. data/lib/mistri/stores/jsonl.rb +37 -0
  51. data/lib/mistri/stores/memory.rb +22 -0
  52. data/lib/mistri/sub_agent.rb +211 -0
  53. data/lib/mistri/tool.rb +95 -0
  54. data/lib/mistri/tool_call.rb +18 -0
  55. data/lib/mistri/tool_context.rb +15 -0
  56. data/lib/mistri/tool_executor.rb +87 -0
  57. data/lib/mistri/tool_result.rb +23 -0
  58. data/lib/mistri/tools/edit_file.rb +37 -0
  59. data/lib/mistri/tools/find_in_file.rb +36 -0
  60. data/lib/mistri/tools/list_files.rb +16 -0
  61. data/lib/mistri/tools/read_file.rb +38 -0
  62. data/lib/mistri/tools/read_memory.rb +16 -0
  63. data/lib/mistri/tools/update_memory.rb +22 -0
  64. data/lib/mistri/tools/write_file.rb +20 -0
  65. data/lib/mistri/tools.rb +50 -0
  66. data/lib/mistri/transport.rb +228 -0
  67. data/lib/mistri/usage.rb +79 -0
  68. data/lib/mistri/version.rb +1 -1
  69. data/lib/mistri/workspace/active_record.rb +47 -0
  70. data/lib/mistri/workspace/directory.rb +52 -0
  71. data/lib/mistri/workspace/memory.rb +40 -0
  72. data/lib/mistri/workspace/single.rb +48 -0
  73. data/lib/mistri.rb +89 -0
  74. metadata +79 -10
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mistri
6
+ module Providers
7
+ class OpenAI
8
+ # Folds the Responses API stream into the event union. Items arrive
9
+ # sequentially: output_item.added opens a block, typed deltas fill it,
10
+ # output_item.done closes it with the complete item, whose ids and
11
+ # encrypted reasoning land in the signature slots for replay.
12
+ #
13
+ # Unknown event and item types are skipped by contract.
14
+ class Assembler
15
+ def initialize(model:)
16
+ @model = model
17
+ @blocks = []
18
+ @current = nil
19
+ @usage = Usage.zero
20
+ @status = nil
21
+ @incomplete_reason = nil
22
+ end
23
+
24
+ def feed(record, &)
25
+ case record["type"]
26
+ when "response.output_item.added" then start_item(record["item"], &)
27
+ when "response.output_text.delta" then text_delta(record["delta"], &)
28
+ when "response.reasoning_summary_text.delta" then thinking_delta(record["delta"], &)
29
+ when "response.function_call_arguments.delta" then arguments_delta(record["delta"], &)
30
+ when "response.output_item.done" then finish_item(record["item"], &)
31
+ when "response.completed", "response.incomplete", "response.failed"
32
+ finish_response(record["response"] || {})
33
+ when "error" then @error = wire_error(record)
34
+ end
35
+ end
36
+
37
+ # A stream that ended without a terminal response event was truncated,
38
+ # not cancelled: fail it so the loop can treat it as retryable.
39
+ def finish(&emit)
40
+ return fail_stream(@error, &emit) if @error
41
+ return fail_stream("stream ended without a terminal event", &emit) unless @status
42
+
43
+ @message = assemble(stop_reason: stop_reason)
44
+ emit&.call(Event.new(type: :done, reason: @message.stop_reason, message: @message))
45
+ @message
46
+ end
47
+
48
+ def abort(&)
49
+ terminal(StopReason::ABORTED, "aborted", &)
50
+ end
51
+
52
+ # In-stream failures carry a code; rate limits and server errors must
53
+ # classify as retryable, not fold into prose.
54
+ def wire_error(record)
55
+ message = record["message"] || "provider error"
56
+ code = record["code"].to_s
57
+ klass = if code.include?("rate_limit") then RateLimitError
58
+ elsif code.include?("server") then ServerError
59
+ else ProviderError
60
+ end
61
+ klass.new(message)
62
+ end
63
+
64
+ def fail_stream(reason, &)
65
+ text = case reason
66
+ when ProviderError then "#{reason.class}: #{reason.describe}"
67
+ when Exception then "#{reason.class}: #{reason.message}"
68
+ else reason.to_s
69
+ end
70
+ terminal(StopReason::ERROR, text, error: ErrorData.for(reason), &)
71
+ end
72
+
73
+ def message = @message ||= finish
74
+
75
+ Builder = Struct.new(:kind, :index, :text, :json)
76
+ KINDS = { "message" => :text, "reasoning" => :thinking,
77
+ "function_call" => :toolcall }.freeze
78
+
79
+ private
80
+
81
+ def start_item(item, &)
82
+ kind = KINDS[item&.fetch("type", nil)]
83
+ return unless kind
84
+
85
+ @current = Builder.new(kind, @blocks.size, +"", +"")
86
+ emit_event(:"#{kind}_start", content_index: @current.index, &)
87
+ end
88
+
89
+ def text_delta(delta, &)
90
+ return unless @current
91
+
92
+ @current.text << delta.to_s
93
+ emit_event(:text_delta, content_index: @current.index, delta: delta, &)
94
+ end
95
+
96
+ def thinking_delta(delta, &)
97
+ return unless @current
98
+
99
+ @current.text << delta.to_s
100
+ emit_event(:thinking_delta, content_index: @current.index, delta: delta, &)
101
+ end
102
+
103
+ def arguments_delta(delta, &)
104
+ return unless @current
105
+
106
+ @current.json << delta.to_s
107
+ emit_event(:toolcall_delta, content_index: @current.index, delta: delta, &)
108
+ end
109
+
110
+ # The done item is authoritative: its text, arguments, ids, and
111
+ # encrypted content replace whatever the deltas accumulated.
112
+ def finish_item(item, &)
113
+ kind = KINDS[item&.fetch("type", nil)]
114
+ return unless kind
115
+
116
+ index = @current&.index || @blocks.size
117
+ block = build_block(kind, item)
118
+ @blocks << block
119
+ @current = nil
120
+ fields = { content_index: index }
121
+ fields[:tool_call] = block if block.is_a?(ToolCall)
122
+ unless block.is_a?(ToolCall)
123
+ fields[:content] = block.respond_to?(:text) ? block.text : block.thinking
124
+ end
125
+ emit_event(:"#{kind}_end", **fields.compact, &)
126
+ end
127
+
128
+ def build_block(kind, item)
129
+ case kind
130
+ when :text
131
+ text = Array(item["content"]).filter_map { |part| part["text"] }.join
132
+ Content::Text.new(text: text, signature: item["id"])
133
+ when :thinking
134
+ summary = Array(item["summary"]).filter_map { |part| part["text"] }.join
135
+ Content::Thinking.new(thinking: summary, signature: JSON.generate(item))
136
+ when :toolcall
137
+ ToolCall.new(id: item["call_id"], name: item["name"],
138
+ arguments: parse_arguments(item["arguments"]), signature: item["id"])
139
+ end
140
+ end
141
+
142
+ def parse_arguments(raw)
143
+ parsed = raw.to_s.strip.empty? ? {} : JSON.parse(raw)
144
+ parsed.is_a?(Hash) ? parsed : {}
145
+ rescue JSON::ParserError
146
+ fallback = PartialJson.parse(raw)
147
+ fallback.is_a?(Hash) ? fallback : {}
148
+ end
149
+
150
+ def finish_response(response)
151
+ @status = response["status"] || "completed"
152
+ @incomplete_reason = response.dig("incomplete_details", "reason")
153
+ @error = response.dig("error", "message") if @status == "failed"
154
+ usage = response["usage"]
155
+ @usage = parse_usage(usage) if usage
156
+ end
157
+
158
+ def stop_reason
159
+ return StopReason::LENGTH if @incomplete_reason == "max_output_tokens"
160
+ return StopReason::TOOL_USE if @blocks.any?(ToolCall)
161
+
162
+ StopReason::STOP
163
+ end
164
+
165
+ def terminal(reason, text, error: nil, &emit)
166
+ @message = assemble(stop_reason: reason, error_message: text, error: error)
167
+ emit&.call(Event.new(type: :error, reason: reason, message: @message,
168
+ error_message: text))
169
+ @message
170
+ end
171
+
172
+ def emit_event(type, **fields, &emit)
173
+ emit&.call(Event.new(type:, partial: assemble, **fields))
174
+ end
175
+
176
+ def assemble(**meta)
177
+ blocks = @blocks.dup
178
+ blocks << partial_block(@current) if @current
179
+ Message.assistant(content: blocks, model: @model, provider: :openai,
180
+ usage: @usage, **meta)
181
+ end
182
+
183
+ def partial_block(builder)
184
+ case builder.kind
185
+ when :text then Content::Text.new(text: builder.text)
186
+ when :thinking then Content::Thinking.new(thinking: builder.text)
187
+ when :toolcall
188
+ args = PartialJson.parse(builder.json)
189
+ ToolCall.new(id: "pending", name: "pending",
190
+ arguments: args.is_a?(Hash) ? args : {})
191
+ end
192
+ end
193
+
194
+ def parse_usage(raw)
195
+ details = raw["input_tokens_details"] || {}
196
+ output_details = raw["output_tokens_details"] || {}
197
+ cache_read = details["cached_tokens"].to_i
198
+ Usage.new(input: [raw["input_tokens"].to_i - cache_read, 0].max,
199
+ output: raw["output_tokens"].to_i, cache_read: cache_read,
200
+ reasoning: output_details["reasoning_tokens"].to_i)
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mistri
6
+ module Providers
7
+ class OpenAI
8
+ # Serializes protocol messages into Responses API input items.
9
+ #
10
+ # Replay pairing is the rule that matters: with store: false the full
11
+ # history resends every turn, and a reasoning item must return VERBATIM
12
+ # (its encrypted_content is the model's chain of thought) followed by
13
+ # the same items it originally preceded. The signature slots carry what
14
+ # pairing needs: Thinking.signature holds the whole reasoning item,
15
+ # Text.signature the message item id, ToolCall.signature the
16
+ # function_call item id.
17
+ module Serializer
18
+ module_function
19
+
20
+ def input_items(history)
21
+ history.reject(&:system?).flat_map { |msg| items_for(msg) }
22
+ end
23
+
24
+ def tools(definitions)
25
+ definitions.map do |tool|
26
+ spec = tool.transform_keys(&:to_sym)
27
+ { type: "function", name: spec[:name], description: spec[:description],
28
+ parameters: spec[:input_schema] }
29
+ end
30
+ end
31
+
32
+ def items_for(msg)
33
+ case msg.role
34
+ when :user then [user_item(msg)]
35
+ when :tool then [tool_result_item(msg)]
36
+ when :assistant then assistant_items(msg)
37
+ else []
38
+ end
39
+ end
40
+
41
+ def user_item(msg)
42
+ { role: "user", content: msg.content.map { |block| user_block(block) } }
43
+ end
44
+
45
+ def user_block(block)
46
+ case block
47
+ when Content::Text then { type: "input_text", text: block.text }
48
+ when Content::Image
49
+ { type: "input_image", image_url: "data:#{block.mime_type};base64,#{block.data}" }
50
+ else
51
+ raise SchemaError, "cannot serialize #{block.class} for OpenAI user input"
52
+ end
53
+ end
54
+
55
+ # Non-text blocks in a tool result have no function_call_output
56
+ # encoding; note the omission rather than dropping it silently.
57
+ def tool_result_item(msg)
58
+ omitted = msg.content.count { |block| !block.is_a?(Content::Text) }
59
+ text = msg.text.to_s
60
+ text = "#{text}\n[#{omitted} non-text block(s) omitted]".strip if omitted.positive?
61
+ { type: "function_call_output", call_id: msg.tool_call_id, output: text }
62
+ end
63
+
64
+ def assistant_items(msg)
65
+ msg.content.filter_map { |block| assistant_item(block) }
66
+ end
67
+
68
+ def assistant_item(block)
69
+ case block
70
+ when Content::Thinking then reasoning_item(block)
71
+ when Content::Text then message_item(block)
72
+ when ToolCall then function_call_item(block)
73
+ end
74
+ end
75
+
76
+ # The reasoning item replays exactly as it arrived or not at all. An
77
+ # item without encrypted_content triggers a server-side id lookup that
78
+ # cannot succeed under store: false, so it drops rather than 404s.
79
+ def reasoning_item(block)
80
+ return nil unless block.signature
81
+
82
+ item = JSON.parse(block.signature)
83
+ item.is_a?(Hash) && item["encrypted_content"] ? item : nil
84
+ rescue JSON::ParserError
85
+ nil
86
+ end
87
+
88
+ def message_item(block)
89
+ item = { type: "message", role: "assistant",
90
+ content: [{ type: "output_text", text: block.text }] }
91
+ item[:id] = block.signature if block.signature
92
+ item
93
+ end
94
+
95
+ def function_call_item(block)
96
+ item = { type: "function_call", call_id: block.id, name: block.name,
97
+ arguments: JSON.generate(block.arguments) }
98
+ item[:id] = block.signature if block.signature
99
+ item
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Providers
5
+ # The OpenAI Responses API, streamed and stateless: store is always false,
6
+ # the full history replays every turn, and encrypted reasoning items round
7
+ # trip through the signature slots so nothing depends on server-side
8
+ # state. Reasoning summaries stream as thinking. max_output_tokens is
9
+ # deliberately omitted: the API defaults to the model's own ceiling.
10
+ #
11
+ # Provider failures fold into the stream as an error turn rather than
12
+ # raising, matching the Anthropic provider's contract.
13
+ class OpenAI
14
+ DEFAULT_REASONING = { summary: "auto" }.freeze
15
+
16
+ def initialize(api_key:, model: "gpt-5.5", origin: "https://api.openai.com",
17
+ reasoning: DEFAULT_REASONING, **transport_options)
18
+ @api_key = api_key
19
+ @model = model
20
+ @reasoning = reasoning
21
+ @transport = Transport.new(origin: origin, **transport_options)
22
+ end
23
+
24
+ attr_reader :model
25
+
26
+ def stream(messages:, system: nil, tools: [], signal: nil, **overrides, &emit)
27
+ model = overrides.fetch(:model, @model)
28
+ assembler = OpenAI::Assembler.new(model: model)
29
+ body = build_body(model, messages, system, tools, overrides)
30
+ outcome = @transport.stream_post("/v1/responses", body: body, headers: headers,
31
+ signal: signal) do |record|
32
+ assembler.feed(
33
+ record, &emit
34
+ )
35
+ end
36
+ outcome == :aborted ? assembler.abort(&emit) : assembler.finish(&emit)
37
+ rescue Error => e
38
+ assembler.fail_stream(e, &emit)
39
+ end
40
+
41
+ def close = @transport.close
42
+
43
+ private
44
+
45
+ def build_body(model, messages, system, tools, overrides)
46
+ body = {
47
+ model: model,
48
+ input: Serializer.input_items(messages),
49
+ stream: true,
50
+ store: false,
51
+ include: ["reasoning.encrypted_content"]
52
+ }
53
+ body[:instructions] = system if system && !system.empty?
54
+ body[:tools] = Serializer.tools(tools) if tools.any?
55
+ reasoning = overrides.fetch(:reasoning, @reasoning)
56
+ body[:reasoning] = reasoning if reasoning
57
+ if (schema = overrides[:output_schema])
58
+ body[:text] = { format: { type: "json_schema", name: "output", strict: true,
59
+ schema: Schema.strict(schema, all_required: true) } }
60
+ end
61
+ body
62
+ end
63
+
64
+ def headers
65
+ { "Authorization" => "Bearer #{@api_key}" }
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ require_relative "openai/serializer"
72
+ require_relative "openai/assembler"
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # A periodic reminder for long agentic runs: models drift from their
5
+ # instructions as turns accumulate, and a short reminder at the tail of
6
+ # the context, where attention is strongest, pulls them back. It rides
7
+ # transform_context, so it appears fresh on the wire each time it is due
8
+ # and never persists to the session.
9
+ #
10
+ # agent = Mistri.agent("claude-opus-4-8", tools: tools,
11
+ # transform_context: Mistri::Reminder.every(
12
+ # 3, "Stay on gifting. Verify with tools before claiming.",
13
+ # ))
14
+ #
15
+ # Due is counted in completed assistant turns: the first reminder lands
16
+ # once `after` turns have finished (default: one full interval), then
17
+ # every `interval` turns.
18
+ class Reminder
19
+ def self.every(interval, text, after: nil)
20
+ new(interval: interval, text: text, after: after)
21
+ end
22
+
23
+ def initialize(interval:, text:, after: nil)
24
+ @interval = [interval.to_i, 1].max
25
+ @after = (after || @interval).to_i
26
+ @body = "<system-reminder>\n#{text}\n</system-reminder>"
27
+ end
28
+
29
+ def call(messages)
30
+ turns = messages.count(&:assistant?)
31
+ return messages unless turns >= @after && ((turns - @after) % @interval).zero?
32
+
33
+ messages + [Message.user(@body)]
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # The outcome of a run. A run either finishes, or suspends because a tool
5
+ # needs a human's approval, or stops on abort or budget. Suspension is a
6
+ # first-class outcome, not an error: the run returns immediately with
7
+ # awaiting_approval? true and the pending calls, so nothing blocks waiting
8
+ # for a decision that may come days later.
9
+ #
10
+ # Reads delegate to the final message, so result.text works whether the run
11
+ # completed or suspended.
12
+ # output is a task's validated value, nil on plain runs. usage is the
13
+ # run's own accounting: every persisted turn plus compaction calls, summed
14
+ # (a resumed run counts from the resume; task sums across its fix passes).
15
+ Result = Data.define(:message, :status, :pending, :output, :usage) do
16
+ def initialize(message:, status:, pending: [], output: nil, usage: Usage.zero)
17
+ super
18
+ end
19
+
20
+ def completed? = status == :completed
21
+ def awaiting_approval? = status == :awaiting_approval
22
+ def aborted? = status == :aborted
23
+ def stopped_by_budget? = status == :budget
24
+ def errored? = status == :error
25
+
26
+ def text = message&.text
27
+ def stop_reason = message&.stop_reason
28
+ def error_message = message&.error_message
29
+ def tool_calls = message ? message.tool_calls : []
30
+ def to_s = text.to_s
31
+ end
32
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # When a failed turn is worth retrying, and how long to wait. Transient
5
+ # failures (rate limits, overload, server errors, timeouts, dropped or
6
+ # truncated streams) retry with jittered exponential backoff, honoring the
7
+ # provider's retry-after when it sent one. Everything else — auth, invalid
8
+ # requests, our own bugs — fails fast.
9
+ #
10
+ # attempts counts retries, not calls: attempts 3 means up to four requests.
11
+ class RetryPolicy
12
+ RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504, 529].freeze
13
+ RETRYABLE_TYPES = %w[ProviderError RateLimitError OverloadedError ServerError
14
+ TruncatedStream].freeze
15
+
16
+ attr_reader :attempts, :base, :max_delay
17
+
18
+ def initialize(attempts: 3, base: 1.0, max_delay: 30.0)
19
+ @attempts = attempts
20
+ @base = base
21
+ @max_delay = max_delay
22
+ end
23
+
24
+ def retry?(error, attempt)
25
+ attempt <= attempts && retryable?(error)
26
+ end
27
+
28
+ # error is the ErrorData hash from an errored message. A status decides
29
+ # when present; otherwise only known-transient types retry, so schema
30
+ # violations and host bugs never loop.
31
+ def retryable?(error)
32
+ return false unless error
33
+
34
+ status = error["status"]
35
+ return RETRYABLE_STATUSES.include?(status) if status
36
+
37
+ RETRYABLE_TYPES.include?(error["type"])
38
+ end
39
+
40
+ def delay(attempt, retry_after = nil)
41
+ return retry_after.clamp(0.0, max_delay) if retry_after
42
+
43
+ exponential = base * (2**(attempt - 1))
44
+ (exponential * rand(0.5..1.0)).clamp(0.0, max_delay)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # A small builder for tool argument schemas, so a tool declares its inputs
5
+ # in Ruby instead of hand-writing JSON Schema. It emits the object schema
6
+ # every provider accepts:
7
+ #
8
+ # Schema.build do
9
+ # string :city, "City name", required: true
10
+ # string :units, "Temperature units", enum: %w[celsius fahrenheit]
11
+ # integer :days, "Forecast length"
12
+ # end
13
+ #
14
+ # A raw JSON Schema hash is always accepted directly for anything the
15
+ # builder does not cover, so the DSL is a convenience, never a ceiling.
16
+ class Schema
17
+ # instance_exec, not instance_eval: it binds self to the builder without
18
+ # passing an argument, so a zero-arity lambda works as naturally as a proc.
19
+ def self.build(&)
20
+ builder = new
21
+ builder.instance_exec(&)
22
+ builder.to_h
23
+ end
24
+
25
+ def initialize
26
+ @properties = {}
27
+ @required = []
28
+ end
29
+
30
+ %i[string integer number boolean].each do |type|
31
+ define_method(type) do |name, description = nil, required: false, enum: nil, **extra|
32
+ prop = { type: type.to_s }
33
+ prop[:description] = description if description
34
+ prop[:enum] = enum if enum
35
+ @properties[name.to_s] = prop.merge(extra)
36
+ @required << name.to_s if required
37
+ nil
38
+ end
39
+ end
40
+
41
+ def array(name, description = nil, items: { type: "string" }, required: false, **extra)
42
+ prop = { type: "array", items: items }
43
+ prop[:description] = description if description
44
+ @properties[name.to_s] = prop.merge(extra)
45
+ @required << name.to_s if required
46
+ nil
47
+ end
48
+
49
+ def object(name, description = nil, required: false, &)
50
+ prop = self.class.build(&)
51
+ prop[:description] = description if description
52
+ @properties[name.to_s] = prop
53
+ @required << name.to_s if required
54
+ nil
55
+ end
56
+
57
+ def to_h
58
+ schema = { type: "object", properties: @properties }
59
+ schema[:required] = @required unless @required.empty?
60
+ schema
61
+ end
62
+
63
+ class << self
64
+ # Violations of a value against the schema subset the harness emits and
65
+ # providers constrain: types (including type arrays), object properties
66
+ # with required and additionalProperties: false, array items, enum.
67
+ # Empty means valid; entries are human-readable, written to be fed back
68
+ # to a model for one-shot correction.
69
+ def violations(value, schema, path = "$")
70
+ spec = schema.transform_keys(&:to_s)
71
+ mismatch = type_violation(value, spec, path)
72
+ return [mismatch] if mismatch
73
+
74
+ errors = []
75
+ if (enum = spec["enum"]) && !enum.include?(value)
76
+ errors << "#{path} must be one of: #{enum.join(", ")}"
77
+ end
78
+ case value
79
+ when Hash then errors.concat(object_violations(value, spec, path))
80
+ when Array then errors.concat(array_violations(value, spec, path))
81
+ end
82
+ errors
83
+ end
84
+
85
+ # Prepares a schema for constrained decoding: every object gains
86
+ # additionalProperties: false (Anthropic and OpenAI both demand it),
87
+ # and all_required marks every property required (OpenAI strict mode's
88
+ # rule). String keys throughout, ready for the wire.
89
+ def strict(schema, all_required: false)
90
+ spec = schema.transform_keys(&:to_s)
91
+ out = spec.dup
92
+ if spec["type"] == "object" || spec.key?("properties")
93
+ props = (spec["properties"] || {}).to_h do |key, member|
94
+ [key.to_s, strict(member, all_required: all_required)]
95
+ end
96
+ out["properties"] = props
97
+ out["additionalProperties"] = false
98
+ out["required"] = all_required ? props.keys : Array(spec["required"]).map(&:to_s)
99
+ end
100
+ if spec["items"].is_a?(Hash)
101
+ out["items"] =
102
+ strict(spec["items"], all_required: all_required)
103
+ end
104
+ out
105
+ end
106
+
107
+ TYPES = {
108
+ "object" => ->(v) { v.is_a?(Hash) }, "array" => ->(v) { v.is_a?(Array) },
109
+ "string" => ->(v) { v.is_a?(String) }, "integer" => ->(v) { v.is_a?(Integer) },
110
+ "number" => ->(v) { v.is_a?(Numeric) }, "boolean" => ->(v) { [true, false].include?(v) },
111
+ "null" => lambda(&:nil?)
112
+ }.freeze
113
+
114
+ private
115
+
116
+ def type_violation(value, spec, path)
117
+ names = Array(spec["type"]).map(&:to_s)
118
+ return nil if names.empty?
119
+ return nil if names.any? { |name| TYPES.fetch(name, ->(_) { true }).call(value) }
120
+
121
+ "#{path} must be #{names.join(" or ")}, got #{json_type(value)}"
122
+ end
123
+
124
+ def object_violations(value, spec, path)
125
+ props = (spec["properties"] || {}).transform_keys(&:to_s)
126
+ errors = Array(spec["required"]).map(&:to_s).filter_map do |key|
127
+ "#{path}.#{key} is required" unless value.key?(key)
128
+ end
129
+ value.each do |key, member|
130
+ if (member_schema = props[key.to_s])
131
+ errors.concat(violations(member, member_schema, "#{path}.#{key}"))
132
+ elsif spec["additionalProperties"] == false
133
+ errors << "#{path}.#{key} is not allowed"
134
+ end
135
+ end
136
+ errors
137
+ end
138
+
139
+ def array_violations(value, spec, path)
140
+ items = spec["items"]
141
+ return [] unless items.is_a?(Hash)
142
+
143
+ value.each_with_index.flat_map do |member, index|
144
+ violations(member, items, "#{path}[#{index}]")
145
+ end
146
+ end
147
+
148
+ def json_type(value)
149
+ case value
150
+ when Hash then "object"
151
+ when Array then "array"
152
+ when String then "string"
153
+ when Integer then "integer"
154
+ when Numeric then "number"
155
+ when true, false then "boolean"
156
+ when nil then "null"
157
+ else value.class.name
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end