mistri 0.4.1 → 0.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +596 -3
- data/CONTRIBUTING.md +52 -0
- data/README.md +291 -306
- data/SECURITY.md +40 -0
- data/UPGRADING.md +640 -0
- data/assets/logo-animated.svg +30 -0
- data/assets/logo-dark.svg +14 -0
- data/assets/logo-light.svg +14 -0
- data/assets/logo.svg +14 -0
- data/assets/social-preview.png +0 -0
- data/docs/README.md +87 -0
- data/docs/context-and-workspaces.md +378 -0
- data/docs/mcp.md +366 -0
- data/docs/reliability.md +450 -0
- data/docs/sessions.md +295 -0
- data/docs/sub-agents.md +401 -0
- data/docs/tool-contracts.md +324 -0
- data/examples/approval.rb +36 -0
- data/examples/browser.rb +27 -0
- data/examples/page_editor.rb +31 -0
- data/examples/quickstart.rb +21 -0
- data/lib/generators/mistri/install/install_generator.rb +7 -3
- data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
- data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
- data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
- data/lib/mistri/abort_signal.rb +10 -0
- data/lib/mistri/agent.rb +635 -108
- data/lib/mistri/budget.rb +26 -1
- data/lib/mistri/child.rb +186 -0
- data/lib/mistri/compaction.rb +26 -10
- data/lib/mistri/compactor.rb +35 -12
- data/lib/mistri/console.rb +209 -0
- data/lib/mistri/content.rb +9 -3
- data/lib/mistri/dispatchers.rb +49 -0
- data/lib/mistri/errors.rb +83 -4
- data/lib/mistri/event.rb +30 -8
- data/lib/mistri/event_delivery.rb +60 -0
- data/lib/mistri/locks/rails_cache.rb +48 -0
- data/lib/mistri/locks.rb +141 -0
- data/lib/mistri/mcp/client.rb +74 -19
- data/lib/mistri/mcp/egress.rb +216 -0
- data/lib/mistri/mcp/oauth.rb +476 -127
- data/lib/mistri/mcp/wires.rb +115 -23
- data/lib/mistri/mcp.rb +43 -9
- data/lib/mistri/message.rb +21 -11
- data/lib/mistri/models.rb +160 -22
- data/lib/mistri/providers/anthropic/assembler.rb +282 -44
- data/lib/mistri/providers/anthropic/serializer.rb +14 -9
- data/lib/mistri/providers/anthropic.rb +29 -6
- data/lib/mistri/providers/fake.rb +36 -6
- data/lib/mistri/providers/gemini/assembler.rb +148 -21
- data/lib/mistri/providers/gemini/serializer.rb +78 -9
- data/lib/mistri/providers/gemini.rb +31 -5
- data/lib/mistri/providers/openai/assembler.rb +337 -60
- data/lib/mistri/providers/openai/serializer.rb +13 -12
- data/lib/mistri/providers/openai.rb +29 -5
- data/lib/mistri/providers/schema_capabilities.rb +214 -0
- data/lib/mistri/result.rb +8 -3
- data/lib/mistri/retry_policy.rb +2 -2
- data/lib/mistri/schema.rb +893 -75
- data/lib/mistri/session.rb +649 -47
- data/lib/mistri/sinks/coalesced.rb +17 -10
- data/lib/mistri/skill.rb +1 -1
- data/lib/mistri/skills.rb +1 -1
- data/lib/mistri/spawner.rb +316 -0
- data/lib/mistri/sse.rb +57 -14
- data/lib/mistri/stores/active_record.rb +22 -7
- data/lib/mistri/stores/jsonl.rb +3 -1
- data/lib/mistri/stores/memory.rb +21 -2
- data/lib/mistri/sub_agent/execution.rb +81 -0
- data/lib/mistri/sub_agent/runtime.rb +297 -0
- data/lib/mistri/sub_agent.rb +238 -103
- data/lib/mistri/task_output.rb +58 -0
- data/lib/mistri/tool.rb +102 -13
- data/lib/mistri/tool_arguments.rb +377 -0
- data/lib/mistri/tool_call.rb +43 -9
- data/lib/mistri/tool_context.rb +7 -5
- data/lib/mistri/tool_executor.rb +117 -26
- data/lib/mistri/tool_result.rb +15 -10
- data/lib/mistri/tools/edit_file.rb +62 -8
- data/lib/mistri/tools.rb +41 -4
- data/lib/mistri/transport.rb +149 -44
- data/lib/mistri/usage.rb +65 -13
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri/workspace/active_record.rb +183 -3
- data/lib/mistri/workspace/directory.rb +28 -8
- data/lib/mistri/workspace/memory.rb +34 -9
- data/lib/mistri/workspace/single.rb +62 -5
- data/lib/mistri/workspace.rb +39 -0
- data/lib/mistri.rb +17 -1
- data/mistri.gemspec +34 -0
- metadata +38 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "securerandom"
|
|
4
5
|
|
|
5
6
|
module Mistri
|
|
6
7
|
module Providers
|
|
@@ -26,10 +27,17 @@ module Mistri
|
|
|
26
27
|
|
|
27
28
|
def initialize(turns: [], chunk_size: 12)
|
|
28
29
|
@turns = turns.map { |turn| turn.transform_keys(&:to_sym) }
|
|
30
|
+
@prices_usage = @turns.all? do |turn|
|
|
31
|
+
!turn[:usage] || turn[:usage].cost.known?
|
|
32
|
+
end
|
|
29
33
|
@chunk_size = [chunk_size, 1].max
|
|
30
34
|
@requests = []
|
|
35
|
+
@tool_call_prefix = SecureRandom.uuid
|
|
36
|
+
@tool_call_sequence = 0
|
|
31
37
|
end
|
|
32
38
|
|
|
39
|
+
def prices_usage? = @prices_usage
|
|
40
|
+
|
|
33
41
|
def stream(messages: [], **options, &emit)
|
|
34
42
|
# Snapshot the array: the loop appends replies to it in place.
|
|
35
43
|
@requests << { messages: messages.dup, options: }
|
|
@@ -67,18 +75,40 @@ module Mistri
|
|
|
67
75
|
kind == :text ? Content::Text.new(text:) : Content::Thinking.new(thinking: text)
|
|
68
76
|
end
|
|
69
77
|
|
|
70
|
-
|
|
78
|
+
# Small scripted arguments parse eagerly on every chunk for precise UI
|
|
79
|
+
# tests. Real assemblers cap their refresh schedule under hostile fragmentation.
|
|
80
|
+
def stream_tool_call(spec, _position, blocks, emit)
|
|
71
81
|
spec = spec.transform_keys(&:to_sym)
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
arguments = spec.key?(:arguments) ? spec[:arguments] : {}
|
|
83
|
+
id = spec.key?(:id) ? spec[:id] : next_tool_call_id
|
|
84
|
+
call = ToolCall.new(id:, name: spec[:name], arguments:, signature: spec[:signature],
|
|
85
|
+
arguments_error: spec[:arguments_error],
|
|
86
|
+
provider_call_id: spec[:provider_call_id])
|
|
74
87
|
index = blocks.size
|
|
75
88
|
emit_event(emit, :toolcall_start, blocks, content_index: index)
|
|
76
|
-
|
|
77
|
-
|
|
89
|
+
built = +""
|
|
90
|
+
JSON.generate(call.arguments).scan(/.{1,#{@chunk_size}}/m) do |chunk|
|
|
91
|
+
built << chunk
|
|
92
|
+
emit_event(emit, :toolcall_delta, blocks + [in_progress(call, built)],
|
|
93
|
+
content_index: index, delta: chunk)
|
|
94
|
+
end
|
|
78
95
|
blocks << call
|
|
79
96
|
emit_event(emit, :toolcall_end, blocks, content_index: index, tool_call: call)
|
|
80
97
|
end
|
|
81
98
|
|
|
99
|
+
def in_progress(call, json)
|
|
100
|
+
parsed = ToolArguments.freeze_partial(PartialJson.parse(json))
|
|
101
|
+
ToolCall.new(id: call.id, name: call.name,
|
|
102
|
+
arguments: parsed, signature: call.signature,
|
|
103
|
+
provider_call_id: call.provider_call_id,
|
|
104
|
+
canonicalize: false)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def next_tool_call_id
|
|
108
|
+
@tool_call_sequence += 1
|
|
109
|
+
"fake_#{@tool_call_prefix}_#{@tool_call_sequence}"
|
|
110
|
+
end
|
|
111
|
+
|
|
82
112
|
def finish(turn, blocks, emit)
|
|
83
113
|
reason = turn[:stop_reason] ||
|
|
84
114
|
(blocks.any?(ToolCall) ? StopReason::TOOL_USE : StopReason::STOP)
|
|
@@ -90,7 +120,7 @@ module Mistri
|
|
|
90
120
|
def finish_error(turn, blocks, emit)
|
|
91
121
|
error = { "type" => turn.fetch(:error_type, "Error") }
|
|
92
122
|
error["status"] = turn[:status] if turn[:status]
|
|
93
|
-
message = assemble(blocks, usage: Usage.zero, stop_reason: StopReason::ERROR,
|
|
123
|
+
message = assemble(blocks, usage: turn[:usage] || Usage.zero, stop_reason: StopReason::ERROR,
|
|
94
124
|
error_message: turn[:error], error: error)
|
|
95
125
|
emit&.call(Event.new(type: :error, reason: StopReason::ERROR, message:,
|
|
96
126
|
error_message: turn[:error]))
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "securerandom"
|
|
4
5
|
|
|
5
6
|
module Mistri
|
|
6
7
|
module Providers
|
|
@@ -13,46 +14,104 @@ module Mistri
|
|
|
13
14
|
# Thought signatures ride on individual parts and are captured onto the
|
|
14
15
|
# block they arrived with, verbatim, for replay.
|
|
15
16
|
class Assembler
|
|
16
|
-
def initialize(model:)
|
|
17
|
+
def initialize(model:, catalog_pricing: true, service_tier: nil)
|
|
17
18
|
@model = model
|
|
19
|
+
@catalog_pricing = catalog_pricing
|
|
20
|
+
@service_tier = service_tier
|
|
21
|
+
@pricing_at = Time.now
|
|
18
22
|
@blocks = []
|
|
19
23
|
@current = nil
|
|
20
|
-
@usage = Usage.
|
|
24
|
+
@usage = Usage.new
|
|
21
25
|
@finish_reason = nil
|
|
26
|
+
@terminal = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Finish reasons that are the provider's verdict on the content
|
|
30
|
+
# itself: a retry of the same input meets the same filter, and a
|
|
31
|
+
# harness that re-rolls against a policy verdict is machinery for
|
|
32
|
+
# evading it, so these fail fast.
|
|
33
|
+
VERDICTS = %w[SAFETY RECITATION LANGUAGE BLOCKLIST PROHIBITED_CONTENT SPII
|
|
34
|
+
IMAGE_SAFETY IMAGE_PROHIBITED_CONTENT IMAGE_RECITATION].freeze
|
|
35
|
+
# Missing replay state accuses the request rather than the generated
|
|
36
|
+
# content, but retrying the same history is equally deterministic.
|
|
37
|
+
INPUT_ERRORS = %w[MISSING_THOUGHT_SIGNATURE].freeze
|
|
38
|
+
# The model fumbled its own output (an invalid or runaway tool call,
|
|
39
|
+
# a missing image), or the API stopped for a reason it cannot name
|
|
40
|
+
# (OTHER is documented as "Unknown reason"). The input stands accused
|
|
41
|
+
# of nothing, so these error retryably; a regeneration usually lands.
|
|
42
|
+
FUMBLES = %w[MALFORMED_FUNCTION_CALL UNEXPECTED_TOOL_CALL TOO_MANY_TOOL_CALLS
|
|
43
|
+
NO_IMAGE OTHER IMAGE_OTHER MALFORMED_RESPONSE
|
|
44
|
+
FINISH_REASON_UNSPECIFIED].freeze
|
|
45
|
+
|
|
46
|
+
def start(&emit)
|
|
47
|
+
emit&.call(Event.new(type: :start, partial: assemble))
|
|
22
48
|
end
|
|
23
49
|
|
|
24
50
|
def feed(record, &)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
51
|
+
return after_terminal(record, &) if @terminal
|
|
52
|
+
return record_error(record["error"]) if record["error"]
|
|
53
|
+
|
|
54
|
+
fold_record(record, &)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def after_terminal(record, &)
|
|
58
|
+
return ingest_usage(record) unless terminal_content?(record)
|
|
59
|
+
|
|
60
|
+
protocol_error("stream continued after its terminal record", &)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def record_error(error)
|
|
64
|
+
@error = ProviderError.new(error["message"] || "provider error",
|
|
65
|
+
status: error["code"])
|
|
66
|
+
@terminal = true
|
|
67
|
+
end
|
|
30
68
|
|
|
69
|
+
def fold_record(record, &)
|
|
70
|
+
block = record.dig("promptFeedback", "blockReason").to_s
|
|
71
|
+
@block_reason = block unless block.empty? || block == "BLOCK_REASON_UNSPECIFIED"
|
|
31
72
|
candidate = record.dig("candidates", 0) || {}
|
|
32
|
-
Array(candidate.dig("content", "parts")).
|
|
73
|
+
Array(candidate.dig("content", "parts")).each_with_index do |part, index|
|
|
74
|
+
fold_part(part, part_boundary: index.positive?, &)
|
|
75
|
+
end
|
|
33
76
|
@finish_reason = candidate["finishReason"] if candidate["finishReason"]
|
|
34
|
-
@
|
|
77
|
+
@terminal = true if @finish_reason || @block_reason
|
|
78
|
+
close_current(&) if @terminal
|
|
79
|
+
ingest_usage(record)
|
|
35
80
|
end
|
|
81
|
+
private :after_terminal, :record_error, :fold_record
|
|
36
82
|
|
|
37
83
|
# A stream that ended without a finishReason was truncated, not
|
|
38
84
|
# cancelled: fail it so the loop can treat it as retryable.
|
|
39
85
|
def finish(&emit)
|
|
40
86
|
return fail_stream(@error, &emit) if @error
|
|
87
|
+
if (refused = blocked)
|
|
88
|
+
return fail_stream(refused, usage_known: @usage_authoritative, &emit)
|
|
89
|
+
end
|
|
41
90
|
return fail_stream("stream ended without a finish reason", &emit) unless @finish_reason
|
|
42
91
|
|
|
92
|
+
if @blocks.any?(ToolCall) && !%w[STOP MAX_TOKENS].include?(@finish_reason)
|
|
93
|
+
invalidate_tool_calls
|
|
94
|
+
reason = ProviderError.new("generation ended before its tool calls were confirmed: " \
|
|
95
|
+
"#{@finish_reason}")
|
|
96
|
+
return fail_stream(reason, usage_known: @usage_authoritative, &emit)
|
|
97
|
+
end
|
|
98
|
+
|
|
43
99
|
close_current(&emit)
|
|
100
|
+
invalidate_cost unless @usage_authoritative
|
|
44
101
|
@message = assemble(stop_reason: stop_reason)
|
|
45
102
|
emit&.call(Event.new(type: :done, reason: @message.stop_reason, message: @message))
|
|
46
103
|
@message
|
|
47
104
|
end
|
|
48
105
|
|
|
49
106
|
def abort(&)
|
|
50
|
-
close_current
|
|
107
|
+
close_current(&)
|
|
108
|
+
invalidate_cost
|
|
51
109
|
terminal(StopReason::ABORTED, "aborted", &)
|
|
52
110
|
end
|
|
53
111
|
|
|
54
|
-
def fail_stream(reason, &)
|
|
55
|
-
close_current
|
|
112
|
+
def fail_stream(reason, usage_known: false, &)
|
|
113
|
+
close_current(&)
|
|
114
|
+
invalidate_cost unless usage_known
|
|
56
115
|
text = case reason
|
|
57
116
|
when ProviderError then "#{reason.class}: #{reason.describe}"
|
|
58
117
|
when Exception then "#{reason.class}: #{reason.message}"
|
|
@@ -67,16 +126,52 @@ module Mistri
|
|
|
67
126
|
|
|
68
127
|
private
|
|
69
128
|
|
|
70
|
-
def fold_part(part, &)
|
|
129
|
+
def fold_part(part, part_boundary: false, &)
|
|
71
130
|
if part.key?("functionCall")
|
|
72
131
|
fold_function_call(part, &)
|
|
73
132
|
elsif part.key?("text")
|
|
74
|
-
fold_text(part, part["thought"] ? :thinking : :text, &)
|
|
133
|
+
fold_text(part, part["thought"] ? :thinking : :text, part_boundary:, &)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def terminal_content?(record)
|
|
138
|
+
return true if record["error"]
|
|
139
|
+
return true unless record.dig("promptFeedback", "blockReason").to_s.empty?
|
|
140
|
+
|
|
141
|
+
candidate = record.dig("candidates", 0)
|
|
142
|
+
candidate && (candidate["finishReason"] ||
|
|
143
|
+
!Array(candidate.dig("content", "parts")).empty?)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def ingest_usage(record)
|
|
147
|
+
raw = record["usageMetadata"]
|
|
148
|
+
return unless raw
|
|
149
|
+
|
|
150
|
+
@service_tier = raw["serviceTier"] if raw.key?("serviceTier")
|
|
151
|
+
@usage = priced(parse_usage(raw))
|
|
152
|
+
@usage_authoritative = true if @terminal
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def protocol_error(message, &)
|
|
156
|
+
@error ||= ProviderError.new(message)
|
|
157
|
+
close_current(&)
|
|
158
|
+
invalidate_tool_calls
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def invalidate_tool_calls
|
|
163
|
+
@blocks.map! do |block|
|
|
164
|
+
if block.is_a?(ToolCall)
|
|
165
|
+
block.with(arguments: nil, arguments_error: "incomplete")
|
|
166
|
+
else
|
|
167
|
+
block
|
|
168
|
+
end
|
|
75
169
|
end
|
|
76
170
|
end
|
|
77
171
|
|
|
78
|
-
def fold_text(part, kind, &)
|
|
79
|
-
close_current(&) if @current && @current.kind != kind
|
|
172
|
+
def fold_text(part, kind, part_boundary:, &)
|
|
173
|
+
close_current(&) if @current && (@current.kind != kind || part_boundary ||
|
|
174
|
+
@current.signature)
|
|
80
175
|
unless @current
|
|
81
176
|
@current = Builder.new(kind, @blocks.size, +"", nil)
|
|
82
177
|
emit_event(:"#{kind}_start", content_index: @current.index, &)
|
|
@@ -92,14 +187,15 @@ module Mistri
|
|
|
92
187
|
def fold_function_call(part, &)
|
|
93
188
|
close_current(&)
|
|
94
189
|
call_spec = part["functionCall"] || {}
|
|
95
|
-
arguments = call_spec
|
|
96
|
-
|
|
190
|
+
arguments = call_spec.key?("args") ? call_spec["args"] : {}
|
|
191
|
+
wire_id = call_spec["id"]
|
|
192
|
+
call = ToolCall.new(id: wire_id || "gemini_#{SecureRandom.uuid}",
|
|
97
193
|
name: call_spec["name"], arguments: arguments,
|
|
98
|
-
signature: part["thoughtSignature"])
|
|
194
|
+
signature: part["thoughtSignature"], provider_call_id: wire_id)
|
|
99
195
|
index = @blocks.size
|
|
100
196
|
emit_event(:toolcall_start, content_index: index, &)
|
|
101
197
|
emit_event(:toolcall_delta, content_index: index,
|
|
102
|
-
delta: JSON.generate(arguments), &)
|
|
198
|
+
delta: JSON.generate(call.arguments), &)
|
|
103
199
|
@blocks << call
|
|
104
200
|
emit_event(:toolcall_end, content_index: index, tool_call: call, &)
|
|
105
201
|
end
|
|
@@ -124,9 +220,26 @@ module Mistri
|
|
|
124
220
|
end
|
|
125
221
|
end
|
|
126
222
|
|
|
223
|
+
# A blocked prompt arrives as promptFeedback with no candidates; a
|
|
224
|
+
# blocked candidate arrives as a verdict finishReason. Either way the
|
|
225
|
+
# error carries the wire word, so hosts tell SAFETY from RECITATION
|
|
226
|
+
# without parsing prose.
|
|
227
|
+
def blocked
|
|
228
|
+
if @block_reason
|
|
229
|
+
return InvalidRequestError.new("the prompt was blocked: #{@block_reason}")
|
|
230
|
+
end
|
|
231
|
+
return unless @finish_reason
|
|
232
|
+
if VERDICTS.include?(@finish_reason) || INPUT_ERRORS.include?(@finish_reason)
|
|
233
|
+
return InvalidRequestError.new("generation stopped: #{@finish_reason}")
|
|
234
|
+
end
|
|
235
|
+
return unless FUMBLES.include?(@finish_reason)
|
|
236
|
+
|
|
237
|
+
ProviderError.new("generation stopped: #{@finish_reason}")
|
|
238
|
+
end
|
|
239
|
+
|
|
127
240
|
def stop_reason
|
|
128
|
-
return StopReason::TOOL_USE if @blocks.any?(ToolCall)
|
|
129
241
|
return StopReason::LENGTH if @finish_reason == "MAX_TOKENS"
|
|
242
|
+
return StopReason::TOOL_USE if @blocks.any?(ToolCall)
|
|
130
243
|
|
|
131
244
|
StopReason::STOP
|
|
132
245
|
end
|
|
@@ -157,6 +270,20 @@ module Mistri
|
|
|
157
270
|
output: raw["candidatesTokenCount"].to_i + reasoning,
|
|
158
271
|
cache_read: cache_read, reasoning: reasoning)
|
|
159
272
|
end
|
|
273
|
+
|
|
274
|
+
def priced(usage)
|
|
275
|
+
return usage unless @catalog_pricing
|
|
276
|
+
|
|
277
|
+
standard = @service_tier.nil? || %w[unspecified standard].include?(@service_tier.to_s)
|
|
278
|
+
return usage unless standard
|
|
279
|
+
|
|
280
|
+
rates = Models.rates(@model, usage:, at: @pricing_at)
|
|
281
|
+
rates ? usage.with_cost(rates) : usage
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def invalidate_cost
|
|
285
|
+
@usage = @usage.with(cost: @usage.cost.with(known: false))
|
|
286
|
+
end
|
|
160
287
|
end
|
|
161
288
|
end
|
|
162
289
|
end
|
|
@@ -12,14 +12,28 @@ module Mistri
|
|
|
12
12
|
# signature would be rejected. Thinking summaries are output-only and
|
|
13
13
|
# never replay. Consecutive user turns stay separate: Gemini accepts
|
|
14
14
|
# them, and mixing a text part into a functionResponse turn makes it
|
|
15
|
-
# answer an empty candidate.
|
|
15
|
+
# answer an empty candidate. Completed tool exchanges from another
|
|
16
|
+
# provider become ordinary text: Gemini rejects foreign functionCall
|
|
17
|
+
# history because it has no Gemini thought signature.
|
|
16
18
|
module Serializer
|
|
17
19
|
module_function
|
|
18
20
|
|
|
19
21
|
def contents(history)
|
|
20
|
-
|
|
22
|
+
result_metadata = tool_result_metadata(history)
|
|
23
|
+
groups = history.reject(&:system?).chunk_while do |a, b|
|
|
24
|
+
a.tool? && b.tool? && native_tool_result?(a, result_metadata) ==
|
|
25
|
+
native_tool_result?(b, result_metadata)
|
|
26
|
+
end
|
|
21
27
|
groups.filter_map do |group|
|
|
22
|
-
group.first.tool?
|
|
28
|
+
if group.first.tool?
|
|
29
|
+
if native_tool_result?(group.first, result_metadata)
|
|
30
|
+
tool_turn(group, result_metadata)
|
|
31
|
+
else
|
|
32
|
+
foreign_tool_turn(group)
|
|
33
|
+
end
|
|
34
|
+
else
|
|
35
|
+
turn(group.first)
|
|
36
|
+
end
|
|
23
37
|
end
|
|
24
38
|
end
|
|
25
39
|
|
|
@@ -33,7 +47,7 @@ module Mistri
|
|
|
33
47
|
declarations = definitions.map do |tool|
|
|
34
48
|
spec = tool.transform_keys(&:to_sym)
|
|
35
49
|
{ name: spec[:name], description: spec[:description],
|
|
36
|
-
|
|
50
|
+
parametersJsonSchema: spec[:input_schema] }
|
|
37
51
|
end
|
|
38
52
|
[{ functionDeclarations: declarations }]
|
|
39
53
|
end
|
|
@@ -47,14 +61,28 @@ module Mistri
|
|
|
47
61
|
|
|
48
62
|
# Gemini pairs a functionResponse to its call by NAME; a wrong name
|
|
49
63
|
# silently mismatches, so a missing one fails loudly instead.
|
|
50
|
-
def tool_turn(group)
|
|
64
|
+
def tool_turn(group, result_metadata = {}.compare_by_identity)
|
|
51
65
|
{ role: "user", parts: group.map do |msg|
|
|
52
66
|
unless msg.tool_name
|
|
53
67
|
raise SchemaError, "Gemini tool results need tool_name to pair with their call"
|
|
54
68
|
end
|
|
55
69
|
|
|
56
|
-
|
|
57
|
-
|
|
70
|
+
key = msg.tool_error? ? "error" : "result"
|
|
71
|
+
response = { name: msg.tool_name, response: { key => result_text(msg) } }
|
|
72
|
+
wire_id = result_metadata.dig(msg, :provider_call_id)
|
|
73
|
+
response[:id] = wire_id if wire_id
|
|
74
|
+
{ functionResponse: response }
|
|
75
|
+
end }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def foreign_tool_turn(group)
|
|
79
|
+
{ role: "user", parts: group.map do |msg|
|
|
80
|
+
raise SchemaError, "Gemini tool results need tool_name" unless msg.tool_name
|
|
81
|
+
|
|
82
|
+
label = msg.tool_error? ? "error" : "result"
|
|
83
|
+
text = "Tool #{JSON.generate(msg.tool_name)} #{label} " \
|
|
84
|
+
"(call #{JSON.generate(msg.tool_call_id)}): #{result_text(msg)}"
|
|
85
|
+
{ text: text }
|
|
58
86
|
end }
|
|
59
87
|
end
|
|
60
88
|
|
|
@@ -84,10 +112,51 @@ module Mistri
|
|
|
84
112
|
case block
|
|
85
113
|
when Content::Text then signed({ text: block.text }, block.signature, own)
|
|
86
114
|
when ToolCall
|
|
87
|
-
|
|
88
|
-
|
|
115
|
+
own ? native_function_call(block) : foreign_function_call(block)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def native_function_call(block)
|
|
121
|
+
arguments = ToolArguments.replay_object(block)
|
|
122
|
+
signature = block.signature if arguments.equal?(block.arguments)
|
|
123
|
+
call = { name: block.name, args: arguments }
|
|
124
|
+
call[:id] = block.provider_call_id if block.provider_call_id
|
|
125
|
+
signed({ functionCall: call }, signature, true)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def foreign_function_call(block)
|
|
129
|
+
arguments = if block.arguments_error
|
|
130
|
+
"unavailable (#{block.arguments_error})"
|
|
131
|
+
else
|
|
132
|
+
JSON.generate(block.arguments)
|
|
133
|
+
end
|
|
134
|
+
{ text: "Tool call #{JSON.generate(block.name)} " \
|
|
135
|
+
"(call #{JSON.generate(block.id)}) with arguments: #{arguments}" }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def native_tool_result?(message, result_metadata)
|
|
139
|
+
result_metadata.dig(message, :provider) == :gemini
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Legacy histories may reuse call_N across turns. Correlating each
|
|
143
|
+
# result to the nearest preceding occurrence keeps a later Gemini call
|
|
144
|
+
# from rewriting an earlier foreign result's wire semantics.
|
|
145
|
+
def tool_result_metadata(history)
|
|
146
|
+
pending = Hash.new { |calls, id| calls[id] = [] }
|
|
147
|
+
metadata = {}.compare_by_identity
|
|
148
|
+
history.each do |message|
|
|
149
|
+
if message.assistant?
|
|
150
|
+
message.tool_calls.each do |call|
|
|
151
|
+
pending[call.id] << { provider: message.provider,
|
|
152
|
+
provider_call_id: call.provider_call_id }.freeze
|
|
153
|
+
end
|
|
154
|
+
elsif message.tool?
|
|
155
|
+
matched = pending[message.tool_call_id].pop
|
|
156
|
+
metadata[message] = matched if matched
|
|
89
157
|
end
|
|
90
158
|
end
|
|
159
|
+
metadata
|
|
91
160
|
end
|
|
92
161
|
|
|
93
162
|
def signed(part, signature, own)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "schema_capabilities"
|
|
4
|
+
|
|
3
5
|
module Mistri
|
|
4
6
|
module Providers
|
|
5
7
|
# The Gemini API (v1beta generateContent), streamed over SSE and
|
|
@@ -11,30 +13,50 @@ module Mistri
|
|
|
11
13
|
# maxOutputTokens is omitted for the same reason: the API defaults to the
|
|
12
14
|
# model's ceiling.
|
|
13
15
|
class Gemini
|
|
16
|
+
DEFAULT_ORIGIN = "https://generativelanguage.googleapis.com"
|
|
14
17
|
DEFAULT_THINKING = { includeThoughts: true }.freeze
|
|
15
18
|
|
|
16
19
|
def initialize(api_key:, model: "gemini-2.5-flash",
|
|
17
|
-
origin:
|
|
18
|
-
|
|
20
|
+
origin: DEFAULT_ORIGIN, thinking: DEFAULT_THINKING,
|
|
21
|
+
service_tier: nil, catalog_pricing: nil, **transport_options)
|
|
19
22
|
@api_key = api_key
|
|
20
23
|
@model = model
|
|
21
24
|
@thinking = thinking
|
|
25
|
+
@service_tier = service_tier
|
|
26
|
+
@catalog_pricing = catalog_pricing.nil? ? official_origin?(origin) : catalog_pricing
|
|
22
27
|
@transport = Transport.new(origin: origin, **transport_options)
|
|
23
28
|
end
|
|
24
29
|
|
|
25
30
|
attr_reader :model
|
|
26
31
|
|
|
32
|
+
def prices_usage?
|
|
33
|
+
tier_known = @service_tier.nil? || %w[unspecified standard].include?(@service_tier.to_s)
|
|
34
|
+
@catalog_pricing && Models.priced?(model) && tier_known
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def native_output_schema(schema)
|
|
38
|
+
return unless Models.find(model)&.provider == :gemini
|
|
39
|
+
|
|
40
|
+
SchemaCapabilities.derive(schema, :gemini)
|
|
41
|
+
end
|
|
42
|
+
|
|
27
43
|
def stream(messages:, system: nil, tools: [], signal: nil, **overrides, &emit)
|
|
44
|
+
delivery = EventDelivery.wrap(emit)
|
|
28
45
|
model = overrides.fetch(:model, @model)
|
|
29
|
-
|
|
46
|
+
service_tier = overrides.fetch(:service_tier, @service_tier)
|
|
47
|
+
assembler = Gemini::Assembler.new(model: model, catalog_pricing: @catalog_pricing,
|
|
48
|
+
service_tier:)
|
|
49
|
+
assembler.start(&delivery)
|
|
30
50
|
body = build_body(messages, system, tools, overrides)
|
|
31
51
|
path = "/v1beta/models/#{model}:streamGenerateContent?alt=sse"
|
|
32
52
|
outcome = @transport.stream_post(path, body: body, headers: headers,
|
|
33
53
|
signal: signal) do |record|
|
|
34
54
|
assembler.feed(record,
|
|
35
|
-
&
|
|
55
|
+
&delivery)
|
|
36
56
|
end
|
|
37
|
-
outcome == :aborted ? assembler.abort(&
|
|
57
|
+
outcome == :aborted ? assembler.abort(&delivery) : assembler.finish(&delivery)
|
|
58
|
+
rescue EventDelivery::Failure => e
|
|
59
|
+
raise EventDelivery.unwrap(e, delivery)
|
|
38
60
|
rescue Error => e
|
|
39
61
|
assembler.fail_stream(e, &emit)
|
|
40
62
|
end
|
|
@@ -43,11 +65,15 @@ module Mistri
|
|
|
43
65
|
|
|
44
66
|
private
|
|
45
67
|
|
|
68
|
+
def official_origin?(origin) = origin.to_s.delete_suffix("/") == DEFAULT_ORIGIN
|
|
69
|
+
|
|
46
70
|
def build_body(messages, system, tools, overrides)
|
|
47
71
|
body = { contents: Serializer.contents(messages) }
|
|
48
72
|
instruction = Serializer.system_instruction(system)
|
|
49
73
|
body[:systemInstruction] = instruction if instruction
|
|
50
74
|
body[:tools] = Serializer.tools(tools) if tools.any?
|
|
75
|
+
service_tier = overrides.fetch(:service_tier, @service_tier)
|
|
76
|
+
body[:serviceTier] = service_tier if service_tier
|
|
51
77
|
config = {}
|
|
52
78
|
thinking = overrides.fetch(:thinking, @thinking)
|
|
53
79
|
config[:thinkingConfig] = thinking if thinking
|