mistri 0.0.2 → 0.1.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 +177 -0
- data/NOTICE +9 -0
- data/README.md +314 -3
- data/lib/generators/mistri/install/install_generator.rb +54 -0
- data/lib/generators/mistri/install/templates/migration.rb.tt +14 -0
- data/lib/generators/mistri/install/templates/model.rb.tt +4 -0
- data/lib/mistri/abort_signal.rb +63 -0
- data/lib/mistri/agent.rb +340 -0
- data/lib/mistri/budget.rb +29 -0
- data/lib/mistri/compaction.rb +78 -0
- data/lib/mistri/compactor.rb +182 -0
- data/lib/mistri/content.rb +89 -0
- data/lib/mistri/edit.rb +238 -0
- data/lib/mistri/errors.rb +94 -0
- data/lib/mistri/event.rb +50 -0
- data/lib/mistri/memory.rb +26 -0
- data/lib/mistri/message.rb +90 -0
- data/lib/mistri/models.rb +43 -0
- data/lib/mistri/partial_json.rb +210 -0
- data/lib/mistri/providers/anthropic/assembler.rb +205 -0
- data/lib/mistri/providers/anthropic/serializer.rb +106 -0
- data/lib/mistri/providers/anthropic.rb +106 -0
- data/lib/mistri/providers/fake.rb +109 -0
- data/lib/mistri/providers/gemini/assembler.rb +163 -0
- data/lib/mistri/providers/gemini/serializer.rb +109 -0
- data/lib/mistri/providers/gemini.rb +73 -0
- data/lib/mistri/providers/openai/assembler.rb +205 -0
- data/lib/mistri/providers/openai/serializer.rb +104 -0
- data/lib/mistri/providers/openai.rb +72 -0
- data/lib/mistri/result.rb +30 -0
- data/lib/mistri/retry_policy.rb +47 -0
- data/lib/mistri/schema.rb +162 -0
- data/lib/mistri/session.rb +124 -0
- data/lib/mistri/sinks/action_cable.rb +30 -0
- data/lib/mistri/sinks/coalesced.rb +61 -0
- data/lib/mistri/sinks/sse.rb +26 -0
- data/lib/mistri/skill.rb +15 -0
- data/lib/mistri/skills.rb +81 -0
- data/lib/mistri/sse.rb +50 -0
- data/lib/mistri/stop_reason.rb +25 -0
- data/lib/mistri/stores/active_record.rb +47 -0
- data/lib/mistri/stores/jsonl.rb +37 -0
- data/lib/mistri/stores/memory.rb +22 -0
- data/lib/mistri/sub_agent.rb +211 -0
- data/lib/mistri/tool.rb +94 -0
- data/lib/mistri/tool_call.rb +18 -0
- data/lib/mistri/tool_context.rb +15 -0
- data/lib/mistri/tool_executor.rb +66 -0
- data/lib/mistri/tool_result.rb +23 -0
- data/lib/mistri/tools/edit_file.rb +37 -0
- data/lib/mistri/tools/find_in_file.rb +36 -0
- data/lib/mistri/tools/list_files.rb +16 -0
- data/lib/mistri/tools/read_file.rb +38 -0
- data/lib/mistri/tools/read_memory.rb +16 -0
- data/lib/mistri/tools/update_memory.rb +22 -0
- data/lib/mistri/tools/write_file.rb +20 -0
- data/lib/mistri/tools.rb +50 -0
- data/lib/mistri/transport.rb +187 -0
- data/lib/mistri/usage.rb +79 -0
- data/lib/mistri/version.rb +3 -1
- data/lib/mistri/workspace/active_record.rb +47 -0
- data/lib/mistri/workspace/directory.rb +52 -0
- data/lib/mistri/workspace/memory.rb +40 -0
- data/lib/mistri/workspace/single.rb +48 -0
- data/lib/mistri.rb +91 -2
- metadata +73 -7
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# Typed content blocks: what a message is made of. Text and thinking on
|
|
5
|
+
# assistant turns, text and images on user and tool-result turns, tool calls
|
|
6
|
+
# alongside them. Blocks are immutable values that compare by content,
|
|
7
|
+
# pattern-match, and round-trip through #to_h / Content.from_h, so sessions
|
|
8
|
+
# replay without the loop knowing block shapes.
|
|
9
|
+
module Content
|
|
10
|
+
# String#to_s returns self, so a caller's mutable buffer would alias into an
|
|
11
|
+
# immutable block; blocks own a frozen copy instead.
|
|
12
|
+
def self.freeze_string(value)
|
|
13
|
+
s = value.to_s
|
|
14
|
+
s.frozen? ? s : s.dup.freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# `signature` carries opaque provider metadata that must round-trip, such as
|
|
18
|
+
# the OpenAI Responses message id and output phase.
|
|
19
|
+
Text = Data.define(:text, :signature) do
|
|
20
|
+
def initialize(text:, signature: nil) = super(text: Content.freeze_string(text), signature:)
|
|
21
|
+
|
|
22
|
+
def type = :text
|
|
23
|
+
|
|
24
|
+
def to_h = { type: :text, text:, signature: }.compact
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A model's reasoning. `signature` is the opaque payload a provider needs to
|
|
28
|
+
# replay the block on a later turn; `redacted` marks reasoning a safety
|
|
29
|
+
# filter hid, leaving only the signature.
|
|
30
|
+
Thinking = Data.define(:thinking, :signature, :redacted) do
|
|
31
|
+
def initialize(thinking:, signature: nil, redacted: false)
|
|
32
|
+
super(thinking: Content.freeze_string(thinking), signature:, redacted:)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def type = :thinking
|
|
36
|
+
|
|
37
|
+
def redacted? = redacted
|
|
38
|
+
|
|
39
|
+
def to_h
|
|
40
|
+
h = { type: :thinking, thinking: }
|
|
41
|
+
h[:signature] = signature if signature
|
|
42
|
+
h[:redacted] = true if redacted
|
|
43
|
+
h
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A base64-encoded image with its MIME type.
|
|
48
|
+
Image = Data.define(:data, :mime_type) do
|
|
49
|
+
# Frozen at the pack site so the initializer's ownership check skips a
|
|
50
|
+
# second copy of what can be a multi-megabyte payload.
|
|
51
|
+
def self.from_bytes(bytes, mime_type:) = new(data: [bytes.b].pack("m0").freeze, mime_type:)
|
|
52
|
+
|
|
53
|
+
def initialize(data:, mime_type:)
|
|
54
|
+
super(data: Content.freeze_string(data), mime_type: Content.freeze_string(mime_type))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def type = :image
|
|
58
|
+
|
|
59
|
+
def bytes = data.unpack1("m0")
|
|
60
|
+
|
|
61
|
+
def to_h = { type: :image, data:, mime_type: }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Coerce a value into a list of blocks: nil becomes none, a String becomes
|
|
65
|
+
# one Text block, blocks pass through, arrays may mix all of these.
|
|
66
|
+
def self.wrap(content)
|
|
67
|
+
Array(content).map do |block|
|
|
68
|
+
block.respond_to?(:type) ? block : Text.new(text: block.to_s)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The inverse of #to_h, used when a session is read back. Keys may be
|
|
73
|
+
# symbols or, after a JSON round-trip, strings.
|
|
74
|
+
def self.from_h(hash)
|
|
75
|
+
h = hash.transform_keys(&:to_s)
|
|
76
|
+
case h["type"].to_s
|
|
77
|
+
when "text" then Text.new(text: h["text"], signature: h["signature"])
|
|
78
|
+
when "thinking"
|
|
79
|
+
Thinking.new(thinking: h["thinking"], signature: h["signature"],
|
|
80
|
+
redacted: h.fetch("redacted", false))
|
|
81
|
+
when "image" then Image.new(data: h["data"], mime_type: h["mime_type"])
|
|
82
|
+
when "tool_call"
|
|
83
|
+
ToolCall.new(id: h["id"], name: h["name"], arguments: h["arguments"] || {},
|
|
84
|
+
signature: h["signature"])
|
|
85
|
+
else raise ArgumentError, "unknown content block type #{h["type"].inspect}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
data/lib/mistri/edit.rb
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# Pure fuzzy text replacement: no files, no I/O. This is the string core that
|
|
5
|
+
# a workspace-backed edit tool calls, so it works the same against a database
|
|
6
|
+
# row as against a file on disk.
|
|
7
|
+
#
|
|
8
|
+
# Each edit's old text must match one region and only one, so an edit can
|
|
9
|
+
# never silently change the wrong place. Matching relaxes in two steps: an
|
|
10
|
+
# exact substring first, then a whitespace-tolerant line match that forgives
|
|
11
|
+
# the indentation and trailing-space drift models introduce when they
|
|
12
|
+
# reproduce code they read. Unmatched regions keep their exact bytes,
|
|
13
|
+
# including the file's original line endings.
|
|
14
|
+
module Edit
|
|
15
|
+
Match = Struct.new(:start, :finish, :replacement, :edit_index)
|
|
16
|
+
Result = Data.define(:content, :count)
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# The model-facing single edit: replace old_string once (unique match
|
|
21
|
+
# required) or everywhere with replace_all. Returns a Result carrying the
|
|
22
|
+
# new content and how many places changed. The replacement adapts to the
|
|
23
|
+
# document's newline style, so an LF-authored new_string dropped into a
|
|
24
|
+
# CRLF document does not mix endings.
|
|
25
|
+
def replace(content, old_string, new_string, replace_all: false)
|
|
26
|
+
old = old_string.to_s
|
|
27
|
+
raise EditError, "old_string is empty" if old.empty?
|
|
28
|
+
|
|
29
|
+
new = adapt_newlines(content, new_string.to_s)
|
|
30
|
+
return replace_every(content, old, new) if replace_all
|
|
31
|
+
|
|
32
|
+
match = locate(content, { old: old, new: new, index: 0 })
|
|
33
|
+
changed = content[0...match.start] + match.replacement + content[match.finish..]
|
|
34
|
+
raise EditError, "the edit changed nothing" if changed == content
|
|
35
|
+
|
|
36
|
+
Result.new(content: changed, count: 1)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Apply edits (each {old:, new:}, string or symbol keys) to content and
|
|
40
|
+
# return the new content. Raises EditError when an edit matches nothing,
|
|
41
|
+
# matches more than once, overlaps another, or changes nothing.
|
|
42
|
+
def apply(content, edits)
|
|
43
|
+
normalized = edits.each_with_index.map { |edit, i| normalize(edit, i) }
|
|
44
|
+
matches = normalized.map { |edit| locate(content, edit) }.sort_by(&:start)
|
|
45
|
+
reject_overlaps(matches)
|
|
46
|
+
|
|
47
|
+
result = matches.reverse.reduce(content) do |text, match|
|
|
48
|
+
text[0...match.start] + match.replacement + text[match.finish..]
|
|
49
|
+
end
|
|
50
|
+
raise EditError, "the edits changed nothing" if result == content
|
|
51
|
+
|
|
52
|
+
result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def normalize(edit, index)
|
|
56
|
+
edit = edit.transform_keys(&:to_sym)
|
|
57
|
+
old = edit[:old].to_s
|
|
58
|
+
raise EditError, "edits[#{index}] has empty old text" if old.empty?
|
|
59
|
+
|
|
60
|
+
{ old: old, new: edit[:new].to_s, index: index }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Exact match first; on a miss, a whitespace-tolerant line match. Either
|
|
64
|
+
# level must resolve to exactly one region. A total miss reports the
|
|
65
|
+
# closest region and its precise difference, so the model's retry can be
|
|
66
|
+
# one-shot.
|
|
67
|
+
def locate(content, edit)
|
|
68
|
+
exact_match(content, edit) || fuzzy_match(content, edit) ||
|
|
69
|
+
raise(EditError, not_found_message(content, edit))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def replace_every(content, old, new)
|
|
73
|
+
count = content.enum_for(:scan, old).count
|
|
74
|
+
raise EditError, not_found_message(content, { old: old, index: 0 }) if count.zero?
|
|
75
|
+
|
|
76
|
+
# Block form keeps both sides literal; a bare string replacement would
|
|
77
|
+
# interpret backslash sequences.
|
|
78
|
+
Result.new(content: content.gsub(old) { new }, count: count)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Match the document's dominant newline style so a replacement authored
|
|
82
|
+
# with bare LF does not mix endings into a CRLF document.
|
|
83
|
+
def adapt_newlines(content, text)
|
|
84
|
+
crlf = content.scan("\r\n").length
|
|
85
|
+
bare = content.scan(/(?<!\r)\n/).length
|
|
86
|
+
return text.gsub(/\r?\n/, "\r\n") if crlf > bare
|
|
87
|
+
|
|
88
|
+
crlf.positive? || bare.positive? ? text.gsub("\r\n", "\n") : text
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def exact_match(content, edit)
|
|
92
|
+
offsets = occurrence_offsets(content, edit[:old])
|
|
93
|
+
return nil if offsets.empty?
|
|
94
|
+
|
|
95
|
+
if offsets.length > 1
|
|
96
|
+
lines = offsets.map { |offset| line_number_at(content, offset) }
|
|
97
|
+
raise EditError, ambiguous_message(edit, lines)
|
|
98
|
+
end
|
|
99
|
+
first = offsets.first
|
|
100
|
+
Match.new(first, first + edit[:old].length, edit[:new], edit[:index])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def occurrence_offsets(content, needle)
|
|
104
|
+
offsets = []
|
|
105
|
+
offset = content.index(needle)
|
|
106
|
+
while offset
|
|
107
|
+
offsets << offset
|
|
108
|
+
offset = content.index(needle, offset + 1)
|
|
109
|
+
end
|
|
110
|
+
offsets
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def line_number_at(content, offset) = content[0...offset].count("\n") + 1
|
|
114
|
+
|
|
115
|
+
def ambiguous_message(edit, line_numbers)
|
|
116
|
+
shown = line_numbers.first(4).join(", ")
|
|
117
|
+
shown += ", ..." if line_numbers.length > 4
|
|
118
|
+
"edits[#{edit[:index]}] old text matched #{line_numbers.length} places " \
|
|
119
|
+
"(lines #{shown}). Add surrounding lines until it is unique, or set " \
|
|
120
|
+
"replace_all: true to change all #{line_numbers.length}."
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Match the old text's lines against a window of content lines, comparing
|
|
124
|
+
# each line stripped of leading and trailing whitespace. The matched region
|
|
125
|
+
# is the exact original bytes those content lines span.
|
|
126
|
+
def fuzzy_match(content, edit)
|
|
127
|
+
lines = line_spans(content)
|
|
128
|
+
wanted = edit[:old].lines.map(&:strip)
|
|
129
|
+
wanted.pop if wanted.last == "" # a trailing newline in old text is not a line to match
|
|
130
|
+
return nil if wanted.empty?
|
|
131
|
+
|
|
132
|
+
windows = matching_windows(lines, wanted)
|
|
133
|
+
return nil if windows.empty?
|
|
134
|
+
|
|
135
|
+
raise EditError, ambiguous_message(edit, windows.map { |w| w + 1 }) if windows.length > 1
|
|
136
|
+
|
|
137
|
+
first = windows.first
|
|
138
|
+
Match.new(lines[first][:start], lines[first + wanted.length - 1][:finish],
|
|
139
|
+
edit[:new], edit[:index])
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# When nothing matched, show the model the closest region and exactly how
|
|
143
|
+
# it differs, so the retry is one shot instead of a guessing loop.
|
|
144
|
+
def not_found_message(content, edit)
|
|
145
|
+
base = "edits[#{edit[:index]}] old text was not found"
|
|
146
|
+
near = nearest_region(content, edit[:old])
|
|
147
|
+
unless near
|
|
148
|
+
return "#{base}. Copy old_string verbatim from read_file output, " \
|
|
149
|
+
"without line-number prefixes."
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
"#{base}. Closest region is lines #{near[:from]}-#{near[:to]}; it differs at " \
|
|
153
|
+
"line #{near[:line]}: your text #{near[:yours].inspect} vs the document's " \
|
|
154
|
+
"#{near[:theirs].inspect}#{near[:hint]}. Copy old_string verbatim from " \
|
|
155
|
+
"read_file output, then resend."
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The window with the most stripped-equal lines, plus its first differing
|
|
159
|
+
# line pair.
|
|
160
|
+
def nearest_region(content, old_text)
|
|
161
|
+
lines = line_spans(content)
|
|
162
|
+
wanted_raw = old_text.lines.map(&:chomp)
|
|
163
|
+
wanted = wanted_raw.map(&:strip)
|
|
164
|
+
wanted.pop && wanted_raw.pop if wanted.last == ""
|
|
165
|
+
return nil if wanted.empty? || lines.length < wanted.length
|
|
166
|
+
|
|
167
|
+
best = best_window(lines, wanted)
|
|
168
|
+
return nil unless best
|
|
169
|
+
|
|
170
|
+
diff_at = (0...wanted.length).find { |j| lines[best + j][:stripped] != wanted[j] }
|
|
171
|
+
return nil unless diff_at
|
|
172
|
+
|
|
173
|
+
yours = wanted_raw[diff_at]
|
|
174
|
+
theirs = content.lines[best + diff_at].to_s.chomp
|
|
175
|
+
hint = yours.strip == theirs.strip ? " (differs only in whitespace)" : ""
|
|
176
|
+
{ from: best + 1, to: best + wanted.length, line: best + diff_at + 1,
|
|
177
|
+
yours: yours, theirs: theirs, hint: hint }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Score windows by per-line bigram similarity, so a one-character typo in
|
|
181
|
+
# a one-line old_string still finds its region. Only a window at least
|
|
182
|
+
# half-similar overall is worth reporting.
|
|
183
|
+
def best_window(lines, wanted)
|
|
184
|
+
best = nil
|
|
185
|
+
best_score = wanted.length / 2.0
|
|
186
|
+
(0..(lines.length - wanted.length)).each do |i|
|
|
187
|
+
score = (0...wanted.length).sum { |j| similarity(lines[i + j][:stripped], wanted[j]) }
|
|
188
|
+
if score > best_score
|
|
189
|
+
best_score = score
|
|
190
|
+
best = i
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
best
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def similarity(left, right)
|
|
197
|
+
return 1.0 if left == right
|
|
198
|
+
return 0.0 if left.empty? || right.empty?
|
|
199
|
+
|
|
200
|
+
pairs_left = bigrams(left)
|
|
201
|
+
pairs_right = bigrams(right)
|
|
202
|
+
return 0.0 if pairs_left.empty? || pairs_right.empty?
|
|
203
|
+
|
|
204
|
+
(2.0 * (pairs_left & pairs_right).length) / (pairs_left.length + pairs_right.length)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def bigrams(text) = (0...(text.length - 1)).map { |i| text[i, 2] }.uniq
|
|
208
|
+
|
|
209
|
+
def matching_windows(lines, wanted)
|
|
210
|
+
(0..(lines.length - wanted.length)).select do |i|
|
|
211
|
+
wanted.each_with_index.all? { |line, j| lines[i + j][:stripped] == line }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Each line with its character span in the original and its stripped form.
|
|
216
|
+
# A leading BOM is invisible to matching, and the first span starts after
|
|
217
|
+
# it, so a replacement at the top of the document never swallows it.
|
|
218
|
+
def line_spans(content)
|
|
219
|
+
offset = 0
|
|
220
|
+
content.lines.map do |line|
|
|
221
|
+
bom = offset.zero? && line.start_with?("\uFEFF") ? 1 : 0
|
|
222
|
+
span = { start: offset + bom, finish: offset + line.length,
|
|
223
|
+
stripped: line.delete_prefix("\uFEFF").strip }
|
|
224
|
+
offset += line.length
|
|
225
|
+
span
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def reject_overlaps(matches)
|
|
230
|
+
matches.each_cons(2) do |a, b|
|
|
231
|
+
next if a.finish <= b.start
|
|
232
|
+
|
|
233
|
+
raise EditError, "edits[#{a.edit_index}] and edits[#{b.edit_index}] overlap; " \
|
|
234
|
+
"merge them or target separate regions"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# Root of every error Mistri raises, so a host can rescue Mistri::Error.
|
|
5
|
+
#
|
|
6
|
+
# Only failures the model cannot recover from raise: configuration, transport,
|
|
7
|
+
# budgets, aborts. A tool that fails during a run becomes an in-band tool
|
|
8
|
+
# result the model can react to, never an exception out of the loop.
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
# Missing or contradictory setup: an unknown model, an absent API key.
|
|
12
|
+
class ConfigurationError < Error; end
|
|
13
|
+
|
|
14
|
+
# A provider request failed. Carries the HTTP status and response body when
|
|
15
|
+
# the transport got that far.
|
|
16
|
+
class ProviderError < Error
|
|
17
|
+
attr_reader :status, :body
|
|
18
|
+
|
|
19
|
+
def initialize(message = nil, status: nil, body: nil)
|
|
20
|
+
@status = status
|
|
21
|
+
@body = body
|
|
22
|
+
super(message || self.class.default_message)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The full story for logs and error turns: the response names the fix far
|
|
26
|
+
# more often than the status line does.
|
|
27
|
+
def describe
|
|
28
|
+
parts = [message]
|
|
29
|
+
parts << "status #{status}" if status
|
|
30
|
+
parts << body.to_s[0, 300] if body && !body.to_s.strip.empty?
|
|
31
|
+
parts.join(" | ")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.default_message = "provider request failed"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class AuthenticationError < ProviderError
|
|
38
|
+
def self.default_message = "invalid or missing API key"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class RateLimitError < ProviderError
|
|
42
|
+
attr_reader :retry_after
|
|
43
|
+
|
|
44
|
+
def initialize(message = nil, retry_after: nil, **)
|
|
45
|
+
@retry_after = retry_after
|
|
46
|
+
super(message, **)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.default_message = "rate limited"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class OverloadedError < ProviderError
|
|
53
|
+
def self.default_message = "provider overloaded"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class ServerError < ProviderError
|
|
57
|
+
def self.default_message = "provider server error"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Tool arguments or structured output that violate their declared schema.
|
|
61
|
+
class SchemaError < Error; end
|
|
62
|
+
|
|
63
|
+
# A run cancelled by the host, raised only when the caller opts into raising.
|
|
64
|
+
class AbortError < Error; end
|
|
65
|
+
|
|
66
|
+
# A run stopped by its turn, token, cost, or wall-clock budget.
|
|
67
|
+
class BudgetError < Error; end
|
|
68
|
+
|
|
69
|
+
# A text edit that did not match uniquely, or overlapped another edit.
|
|
70
|
+
class EditError < Error; end
|
|
71
|
+
|
|
72
|
+
# Compaction could not produce a usable summary.
|
|
73
|
+
class CompactionError < Error; end
|
|
74
|
+
|
|
75
|
+
# The machine-readable shape of a stream failure, carried on errored
|
|
76
|
+
# assistant messages so retry policies and hosts can classify without
|
|
77
|
+
# parsing prose. Strings are the assemblers' synthesized truncation
|
|
78
|
+
# reasons.
|
|
79
|
+
module ErrorData
|
|
80
|
+
module_function
|
|
81
|
+
|
|
82
|
+
def for(reason)
|
|
83
|
+
case reason
|
|
84
|
+
when RateLimitError
|
|
85
|
+
{ "type" => "RateLimitError", "status" => reason.status,
|
|
86
|
+
"retry_after" => reason.retry_after }.compact
|
|
87
|
+
when ProviderError
|
|
88
|
+
{ "type" => reason.class.name.split("::").last, "status" => reason.status }.compact
|
|
89
|
+
when Exception then { "type" => reason.class.name }
|
|
90
|
+
else { "type" => "TruncatedStream" }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/mistri/event.rb
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# One event in a streamed assistant turn. A stream is one :start, then a
|
|
5
|
+
# start/delta/end trio per content block (text, thinking, or toolcall), then
|
|
6
|
+
# exactly one terminal event: :done on success or :error on failure, carrying
|
|
7
|
+
# the complete message and its stop reason.
|
|
8
|
+
#
|
|
9
|
+
# `partial` is an immutable snapshot of the assistant message so far, safe to
|
|
10
|
+
# hold across events. `content_index` is the block's position in that
|
|
11
|
+
# message's content list.
|
|
12
|
+
# origin names the sub-agent an event came from: nil for this agent's own
|
|
13
|
+
# turns, and nesting joins names left to right ("researcher>writer").
|
|
14
|
+
class Event < Data.define(:type, :content_index, :delta, :content, :tool_call,
|
|
15
|
+
:reason, :message, :error_message, :partial, :origin)
|
|
16
|
+
# The stream types come from a provider mid-turn; the loop adds
|
|
17
|
+
# :tool_result after it runs each tool, :approval_needed when a gated
|
|
18
|
+
# call parks for a human, and :compacting/:compaction around a context
|
|
19
|
+
# compaction, so one subscription sees the whole exchange.
|
|
20
|
+
TYPES = %i[
|
|
21
|
+
start
|
|
22
|
+
text_start text_delta text_end
|
|
23
|
+
thinking_start thinking_delta thinking_end
|
|
24
|
+
toolcall_start toolcall_delta toolcall_end
|
|
25
|
+
done error
|
|
26
|
+
tool_result approval_needed
|
|
27
|
+
compacting compaction
|
|
28
|
+
retry
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
def initialize(type:, content_index: nil, delta: nil, content: nil, tool_call: nil,
|
|
32
|
+
reason: nil, message: nil, error_message: nil, partial: nil, origin: nil)
|
|
33
|
+
raise ArgumentError, "unknown event type #{type.inspect}" unless TYPES.include?(type)
|
|
34
|
+
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def done? = type == :done
|
|
39
|
+
|
|
40
|
+
def error? = type == :error
|
|
41
|
+
|
|
42
|
+
def terminal? = done? || error?
|
|
43
|
+
|
|
44
|
+
# Partials are ephemeral streaming state and stay out of serialization.
|
|
45
|
+
def to_h
|
|
46
|
+
{ type:, content_index:, delta:, content:, tool_call: tool_call&.to_h,
|
|
47
|
+
reason:, message: message&.to_h, error_message:, origin: }.compact
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# Durable knowledge that outlives a session, living wherever the host says:
|
|
5
|
+
# an org's row, a user record, a file. What memory means (per org, per
|
|
6
|
+
# user, per project) is the host's call; Mistri only reads and replaces it.
|
|
7
|
+
#
|
|
8
|
+
# memory = Mistri::Memory.new(
|
|
9
|
+
# read: -> { org.agent_memory.to_s },
|
|
10
|
+
# write: ->(text) { org.update!(agent_memory: text) }
|
|
11
|
+
# )
|
|
12
|
+
# agent = Mistri.agent("claude-opus-4-8", tools: [*Mistri::Tools.memory(memory)])
|
|
13
|
+
class Memory
|
|
14
|
+
def initialize(read:, write:)
|
|
15
|
+
@read = read
|
|
16
|
+
@write = write
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def read = @read.call.to_s
|
|
20
|
+
|
|
21
|
+
def replace(content)
|
|
22
|
+
@write.call(content.to_s)
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# One message in a conversation, the single shape every provider translates to
|
|
5
|
+
# and from its wire format. Immutable: streaming builds snapshots, sessions
|
|
6
|
+
# replay values, and nothing aliases across threads.
|
|
7
|
+
#
|
|
8
|
+
# Roles: :system, :user, :assistant, :tool (a tool result linked back by
|
|
9
|
+
# tool_call_id). Assistant messages carry the model and provider that produced
|
|
10
|
+
# them, which is what lets a later turn replay history across models, plus
|
|
11
|
+
# usage and the stop reason.
|
|
12
|
+
#
|
|
13
|
+
# ui is a tool result's host-only channel: it persists with the message and
|
|
14
|
+
# rides its :tool_result event, but no serializer ever sends it to a model.
|
|
15
|
+
# error is the machine-readable failure on an errored turn (ErrorData
|
|
16
|
+
# shape); error_message stays the human story.
|
|
17
|
+
class Message < Data.define(:role, :content, :tool_call_id, :tool_name,
|
|
18
|
+
:model, :provider, :usage, :stop_reason, :error_message, :ui,
|
|
19
|
+
:error)
|
|
20
|
+
ROLES = %i[system user assistant tool].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(role:, content: nil, tool_call_id: nil, tool_name: nil, model: nil,
|
|
23
|
+
provider: nil, usage: nil, stop_reason: nil, error_message: nil, ui: nil,
|
|
24
|
+
error: nil)
|
|
25
|
+
role = role.to_sym
|
|
26
|
+
raise ArgumentError, "unknown role #{role.inspect}" unless ROLES.include?(role)
|
|
27
|
+
if stop_reason && !StopReason.valid?(stop_reason)
|
|
28
|
+
raise ArgumentError, "unknown stop reason #{stop_reason.inspect}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
super(role:, content: Content.wrap(content).freeze, tool_call_id:, tool_name:,
|
|
32
|
+
model:, provider:, usage:, stop_reason:, error_message:, ui:, error:)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.system(content) = new(role: :system, content:)
|
|
36
|
+
|
|
37
|
+
def self.user(content) = new(role: :user, content:)
|
|
38
|
+
|
|
39
|
+
# A user turn carrying images alongside optional text.
|
|
40
|
+
def self.user_with_images(content, images = [])
|
|
41
|
+
images = Array(images)
|
|
42
|
+
return user(content) if images.empty?
|
|
43
|
+
|
|
44
|
+
text = content.to_s
|
|
45
|
+
blocks = text.empty? ? images : [Content::Text.new(text:), *images]
|
|
46
|
+
new(role: :user, content: blocks)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.assistant(content: nil, tool_calls: [], **meta)
|
|
50
|
+
new(role: :assistant, content: [*Content.wrap(content), *tool_calls], **meta)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.tool(content:, tool_call_id:, tool_name: nil, ui: nil)
|
|
54
|
+
new(role: :tool, content:, tool_call_id:, tool_name:, ui:)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.from_h(hash)
|
|
58
|
+
h = hash.transform_keys(&:to_s)
|
|
59
|
+
new(role: h.fetch("role").to_sym,
|
|
60
|
+
content: Array(h["content"]).map { |block| Content.from_h(block) },
|
|
61
|
+
tool_call_id: h["tool_call_id"], tool_name: h["tool_name"],
|
|
62
|
+
model: h["model"], provider: h["provider"]&.to_sym,
|
|
63
|
+
usage: h["usage"] && Usage.from_h(h["usage"]),
|
|
64
|
+
stop_reason: h["stop_reason"]&.to_sym, error_message: h["error_message"],
|
|
65
|
+
ui: h["ui"], error: h["error"])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def system? = role == :system
|
|
69
|
+
def user? = role == :user
|
|
70
|
+
def assistant? = role == :assistant
|
|
71
|
+
def tool? = role == :tool
|
|
72
|
+
|
|
73
|
+
# Every Text block joined, or nil when the turn carried no text.
|
|
74
|
+
def text
|
|
75
|
+
texts = content.grep(Content::Text)
|
|
76
|
+
texts.empty? ? nil : texts.map(&:text).join
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def tool_calls = content.grep(ToolCall)
|
|
80
|
+
|
|
81
|
+
def tool_calls? = content.any?(ToolCall)
|
|
82
|
+
|
|
83
|
+
# A serialization shape, not the member hash: rebuild with .from_h,
|
|
84
|
+
# never with new(**to_h).
|
|
85
|
+
def to_h
|
|
86
|
+
{ role:, content: content.map(&:to_h), tool_call_id:, tool_name:, model:,
|
|
87
|
+
provider:, usage: usage&.to_h, stop_reason:, error_message:, ui:, error: }.compact
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# The model catalog: capability data for known models, with graceful
|
|
5
|
+
# passthrough for unknown ones. An id missing here still works everywhere;
|
|
6
|
+
# the catalog only improves defaults (output ceilings, provider inference),
|
|
7
|
+
# so a brand-new model is usable the day it ships.
|
|
8
|
+
module Models
|
|
9
|
+
# thinking is how the model accepts a reasoning request: :adaptive (the
|
|
10
|
+
# model decides), :budget (a token budget), or :effort. It is what keeps
|
|
11
|
+
# a provider from sending an unsupported thinking shape that 400s.
|
|
12
|
+
Model = Data.define(:id, :provider, :max_output, :context_window, :thinking)
|
|
13
|
+
|
|
14
|
+
CATALOG = [
|
|
15
|
+
["claude-fable-5", :anthropic, 128_000, 200_000, :adaptive],
|
|
16
|
+
["claude-opus-4-8", :anthropic, 128_000, 200_000, :adaptive],
|
|
17
|
+
["claude-opus-4-7", :anthropic, 128_000, 200_000, :adaptive],
|
|
18
|
+
["claude-opus-4-6", :anthropic, 128_000, 200_000, :adaptive],
|
|
19
|
+
["claude-sonnet-5", :anthropic, 128_000, 200_000, :adaptive],
|
|
20
|
+
["claude-sonnet-4-6", :anthropic, 128_000, 200_000, :adaptive],
|
|
21
|
+
["claude-haiku-4-5", :anthropic, 64_000, 200_000, :budget],
|
|
22
|
+
["gpt-5.5", :openai, 128_000, 400_000, :effort],
|
|
23
|
+
["gpt-5.4", :openai, 128_000, 400_000, :effort],
|
|
24
|
+
["gpt-5-nano", :openai, 128_000, 400_000, :effort],
|
|
25
|
+
["gemini-3.5-flash", :gemini, 65_536, 1_048_576, :level],
|
|
26
|
+
["gemini-3.1-pro-preview", :gemini, 65_536, 1_048_576, :level],
|
|
27
|
+
["gemini-2.5-pro", :gemini, 65_536, 1_048_576, :budget],
|
|
28
|
+
["gemini-2.5-flash", :gemini, 65_536, 1_048_576, :budget]
|
|
29
|
+
].to_h do |id, provider, max_output, context_window, thinking|
|
|
30
|
+
[id, Model.new(id:, provider:, max_output:, context_window:, thinking:)]
|
|
31
|
+
end.freeze
|
|
32
|
+
|
|
33
|
+
# Dated aliases resolve to their base entry: claude-opus-4-8-20260115 and
|
|
34
|
+
# gpt-5.4-2025-04-14 both match their base ids.
|
|
35
|
+
def self.find(id)
|
|
36
|
+
CATALOG[id] || CATALOG[id.to_s.sub(/-\d{8}\z/, "").sub(/-\d{4}-\d{2}-\d{2}\z/, "")]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.max_output(id) = find(id)&.max_output
|
|
40
|
+
|
|
41
|
+
def self.thinking(id) = find(id)&.thinking
|
|
42
|
+
end
|
|
43
|
+
end
|