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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +215 -0
- data/README.md +367 -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/generators/mistri/mcp/mcp_generator.rb +57 -0
- data/lib/generators/mistri/mcp/templates/migration.rb.tt +27 -0
- data/lib/generators/mistri/mcp/templates/model.rb.tt +63 -0
- data/lib/mistri/abort_signal.rb +63 -0
- data/lib/mistri/agent.rb +389 -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 +54 -0
- data/lib/mistri/mcp/client.rb +156 -0
- data/lib/mistri/mcp/oauth.rb +286 -0
- data/lib/mistri/mcp/wires.rb +164 -0
- data/lib/mistri/mcp.rb +96 -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/reminder.rb +36 -0
- data/lib/mistri/result.rb +32 -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 +95 -0
- data/lib/mistri/tool_call.rb +18 -0
- data/lib/mistri/tool_context.rb +15 -0
- data/lib/mistri/tool_executor.rb +87 -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 +228 -0
- data/lib/mistri/usage.rb +79 -0
- data/lib/mistri/version.rb +1 -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 +89 -0
- metadata +79 -10
data/lib/mistri/tools.rb
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# Built-in tools. Tools.files binds the document tools to a workspace, so
|
|
5
|
+
# "file" means whatever the workspace says it means: a database column, a
|
|
6
|
+
# row in a documents table, or an actual file. The names stay read_file and
|
|
7
|
+
# edit_file because those are the tool names models are trained on.
|
|
8
|
+
module Tools
|
|
9
|
+
ALIASES = { "oldText" => "old_string", "old" => "old_string", "search" => "old_string",
|
|
10
|
+
"newText" => "new_string", "new" => "new_string", "replace" => "new_string",
|
|
11
|
+
"replaceAll" => "replace_all", "file" => "path", "filename" => "path" }.freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def files(workspace)
|
|
16
|
+
[read_file(workspace), write_file(workspace), edit_file(workspace),
|
|
17
|
+
find_in_file(workspace), list_files(workspace)]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def memory(store)
|
|
21
|
+
[read_memory(store), update_memory(store)]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def with_document(workspace, args)
|
|
25
|
+
content = workspace.read(args["path"])
|
|
26
|
+
return "No document at #{args["path"].inspect}. Use list_files to see paths." if content.nil?
|
|
27
|
+
|
|
28
|
+
yield content
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Absorb the drift real models produce: alias keys, stringly booleans,
|
|
32
|
+
# unknown keys dropped by simply never being read.
|
|
33
|
+
def tolerate(args)
|
|
34
|
+
normalized = args.to_h { |key, value| [ALIASES.fetch(key.to_s, key.to_s), value] }
|
|
35
|
+
case normalized["replace_all"]
|
|
36
|
+
when "true", "1", 1 then normalized["replace_all"] = true
|
|
37
|
+
when "false", "0", 0, nil then normalized["replace_all"] = false
|
|
38
|
+
end
|
|
39
|
+
normalized
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
require_relative "tools/read_file"
|
|
45
|
+
require_relative "tools/write_file"
|
|
46
|
+
require_relative "tools/edit_file"
|
|
47
|
+
require_relative "tools/find_in_file"
|
|
48
|
+
require_relative "tools/list_files"
|
|
49
|
+
require_relative "tools/read_memory"
|
|
50
|
+
require_relative "tools/update_memory"
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Mistri
|
|
8
|
+
# HTTP for one provider origin, held open across the turns of a run: a
|
|
9
|
+
# multi-turn agent pays the TCP and TLS handshake once, not per turn. Not
|
|
10
|
+
# shareable across threads; a mutex serializes accidental concurrent use onto
|
|
11
|
+
# the single socket, and re-entering from a streaming callback raises
|
|
12
|
+
# ThreadError.
|
|
13
|
+
#
|
|
14
|
+
# Streaming reads abort two ways: cooperatively between fragments, and hard,
|
|
15
|
+
# by closing the socket from the abort signal's callback, so a stalled read
|
|
16
|
+
# stops immediately instead of waiting out the read timeout.
|
|
17
|
+
class Transport
|
|
18
|
+
KEEP_ALIVE_SECONDS = 30
|
|
19
|
+
|
|
20
|
+
def initialize(origin:, open_timeout: 15, read_timeout: 300, write_timeout: 60)
|
|
21
|
+
@origin = origin.to_s.chomp("/")
|
|
22
|
+
@uri = URI(@origin)
|
|
23
|
+
@open_timeout = open_timeout
|
|
24
|
+
@read_timeout = read_timeout
|
|
25
|
+
@write_timeout = write_timeout
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
@connection = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# POST and decode a JSON response body. Retries once on a dead idle
|
|
31
|
+
# socket, so it suits idempotent endpoints.
|
|
32
|
+
def post(path, body:, headers: {})
|
|
33
|
+
response = @mutex.synchronize do
|
|
34
|
+
with_retry { connection.request(build_request(path, body, headers)) }
|
|
35
|
+
end
|
|
36
|
+
raise_for_status(response)
|
|
37
|
+
JSON.parse(response.body)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# POST and stream the SSE response, yielding each decoded data record.
|
|
41
|
+
# Returns :aborted when the signal cancelled the stream, else nil.
|
|
42
|
+
def stream_post(path, body:, headers: {}, signal: nil, &block)
|
|
43
|
+
return :aborted if signal&.aborted?
|
|
44
|
+
|
|
45
|
+
@mutex.synchronize { stream_locked(path, body, headers, signal, &block) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# POST for Streamable-HTTP endpoints (the MCP shape) that answer either
|
|
49
|
+
# a JSON body or an SSE stream: yields each JSON record either way and
|
|
50
|
+
# returns the response headers, downcased. Retries only a dead idle
|
|
51
|
+
# socket that failed before any response started, so a side-effecting
|
|
52
|
+
# call can never run twice.
|
|
53
|
+
def post_either(path, body:, headers: {}, &block)
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
retried = false
|
|
56
|
+
begin
|
|
57
|
+
started = false
|
|
58
|
+
response_headers = nil
|
|
59
|
+
connection.request(build_request(path, body, headers, streaming: true)) do |response|
|
|
60
|
+
started = true
|
|
61
|
+
raise_for_status(response)
|
|
62
|
+
response_headers = response.to_hash.transform_values(&:first)
|
|
63
|
+
read_either(response, &block)
|
|
64
|
+
end
|
|
65
|
+
response_headers
|
|
66
|
+
rescue IOError, SocketError, SystemCallError, Timeout::Error => e
|
|
67
|
+
teardown
|
|
68
|
+
if started || retried || e.is_a?(Timeout::Error)
|
|
69
|
+
raise ProviderError, "connection failed: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
retried = true
|
|
73
|
+
retry
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def close
|
|
79
|
+
@mutex.synchronize { teardown }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def read_either(response, &block)
|
|
85
|
+
if response["content-type"].to_s.include?("text/event-stream")
|
|
86
|
+
sse = SSE.new
|
|
87
|
+
response.read_body { |chunk| sse.feed(chunk, &block) }
|
|
88
|
+
sse.finish(&block)
|
|
89
|
+
else
|
|
90
|
+
raw = response.read_body
|
|
91
|
+
block.call(JSON.parse(raw)) unless raw.to_s.strip.empty?
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def stream_locked(path, body, headers, signal, &block)
|
|
96
|
+
retried = false
|
|
97
|
+
begin
|
|
98
|
+
started = false
|
|
99
|
+
aborted = false
|
|
100
|
+
conn = connection
|
|
101
|
+
# The closer targets this turn's connection, so an abort that fires
|
|
102
|
+
# late, after the turn already finished, cannot touch a successor's
|
|
103
|
+
# socket.
|
|
104
|
+
closer = signal&.on_abort { quietly_finish(conn) }
|
|
105
|
+
begin
|
|
106
|
+
conn.request(build_request(path, body, headers, streaming: true)) do |response|
|
|
107
|
+
started = true
|
|
108
|
+
raise_for_status(response)
|
|
109
|
+
aborted = read_stream(response, signal, &block)
|
|
110
|
+
end
|
|
111
|
+
ensure
|
|
112
|
+
signal&.remove_callback(closer) if closer
|
|
113
|
+
end
|
|
114
|
+
# An abort mid-body leaves unread bytes on the socket; drop it rather
|
|
115
|
+
# than let the next request read stale frames.
|
|
116
|
+
teardown if aborted
|
|
117
|
+
aborted ? :aborted : nil
|
|
118
|
+
rescue IOError, SocketError, SystemCallError, Timeout::Error => e
|
|
119
|
+
teardown
|
|
120
|
+
return :aborted if signal&.aborted?
|
|
121
|
+
|
|
122
|
+
# A dead idle keep-alive socket fails before the response starts and
|
|
123
|
+
# retries safely. A drop after events flowed must not replay the turn.
|
|
124
|
+
if !started && !retried
|
|
125
|
+
retried = true
|
|
126
|
+
retry
|
|
127
|
+
end
|
|
128
|
+
raise ProviderError, "connection lost mid-stream: #{e.message}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def read_stream(response, signal, &block)
|
|
133
|
+
sse = SSE.new
|
|
134
|
+
aborted = false
|
|
135
|
+
response.read_body do |fragment|
|
|
136
|
+
if signal&.aborted?
|
|
137
|
+
aborted = true
|
|
138
|
+
break
|
|
139
|
+
end
|
|
140
|
+
sse.feed(fragment, &block)
|
|
141
|
+
end
|
|
142
|
+
sse.finish(&block) unless aborted
|
|
143
|
+
aborted
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def build_request(path, body, headers, streaming: false)
|
|
147
|
+
request = Net::HTTP::Post.new(URI("#{@origin}#{path}"))
|
|
148
|
+
request["Content-Type"] = "application/json"
|
|
149
|
+
if streaming
|
|
150
|
+
request["Accept"] = "text/event-stream"
|
|
151
|
+
# Net::HTTP silently negotiates gzip, and its inflater buffers the
|
|
152
|
+
# whole stream, delivering "live" events in one burst at the end.
|
|
153
|
+
request["Accept-Encoding"] = "identity"
|
|
154
|
+
end
|
|
155
|
+
headers.each { |key, value| request[key] = value }
|
|
156
|
+
request.body = JSON.generate(body)
|
|
157
|
+
request
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def with_retry
|
|
161
|
+
attempted = false
|
|
162
|
+
begin
|
|
163
|
+
yield
|
|
164
|
+
rescue Timeout::Error => e
|
|
165
|
+
# The server may already be executing the request; a replay risks
|
|
166
|
+
# running it twice.
|
|
167
|
+
teardown
|
|
168
|
+
raise ProviderError, "request timed out: #{e.message}"
|
|
169
|
+
rescue IOError, SocketError, SystemCallError => e
|
|
170
|
+
teardown
|
|
171
|
+
raise ProviderError, "connection failed: #{e.message}" if attempted
|
|
172
|
+
|
|
173
|
+
attempted = true
|
|
174
|
+
retry
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def connection
|
|
179
|
+
@connection ||= Net::HTTP.new(@uri.host, @uri.port).tap do |http|
|
|
180
|
+
http.use_ssl = @uri.scheme == "https"
|
|
181
|
+
http.open_timeout = @open_timeout
|
|
182
|
+
http.read_timeout = @read_timeout
|
|
183
|
+
http.write_timeout = @write_timeout
|
|
184
|
+
http.keep_alive_timeout = KEEP_ALIVE_SECONDS
|
|
185
|
+
http.start
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def teardown
|
|
190
|
+
@connection&.finish
|
|
191
|
+
rescue IOError
|
|
192
|
+
nil
|
|
193
|
+
ensure
|
|
194
|
+
@connection = nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def quietly_finish(conn)
|
|
198
|
+
conn.finish
|
|
199
|
+
rescue IOError
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def raise_for_status(response)
|
|
204
|
+
status = response.code.to_i
|
|
205
|
+
return if (200..299).cover?(status)
|
|
206
|
+
|
|
207
|
+
klass = error_class(status)
|
|
208
|
+
options = { status: status, body: response.read_body.to_s[0, 500] }
|
|
209
|
+
options[:retry_after] = retry_after(response) if klass == RateLimitError
|
|
210
|
+
raise klass.new(**options)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def error_class(status)
|
|
214
|
+
case status
|
|
215
|
+
when 401, 403 then AuthenticationError
|
|
216
|
+
when 429 then RateLimitError
|
|
217
|
+
when 529 then OverloadedError
|
|
218
|
+
when 500..599 then ServerError
|
|
219
|
+
else ProviderError
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def retry_after(response)
|
|
224
|
+
value = response["retry-after"]
|
|
225
|
+
value&.match?(/\A\d+(\.\d+)?\z/) ? value.to_f : nil
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
data/lib/mistri/usage.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# Token accounting for an assistant turn, and the dollar cost of those tokens.
|
|
5
|
+
#
|
|
6
|
+
# `input` counts only prompt tokens billed at the full rate: cache reads and
|
|
7
|
+
# writes are separate fields, so a cached token is never double-billed.
|
|
8
|
+
# `reasoning` is the thinking slice of `output`. `cache_write_1h` is the slice
|
|
9
|
+
# of `cache_write` held for an hour, which bills at twice the input rate.
|
|
10
|
+
class Usage < Data.define(:input, :output, :cache_read, :cache_write,
|
|
11
|
+
:cache_write_1h, :reasoning, :cost)
|
|
12
|
+
Cost = Data.define(:input, :output, :cache_read, :cache_write, :total) do
|
|
13
|
+
def self.zero = new(input: 0.0, output: 0.0, cache_read: 0.0, cache_write: 0.0, total: 0.0)
|
|
14
|
+
|
|
15
|
+
def +(other)
|
|
16
|
+
self.class.new(input: input + other.input, output: output + other.output,
|
|
17
|
+
cache_read: cache_read + other.cache_read,
|
|
18
|
+
cache_write: cache_write + other.cache_write,
|
|
19
|
+
total: total + other.total)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(input: 0, output: 0, cache_read: 0, cache_write: 0,
|
|
24
|
+
cache_write_1h: 0, reasoning: 0, cost: Cost.zero)
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.zero = new
|
|
29
|
+
|
|
30
|
+
def self.from_h(hash)
|
|
31
|
+
h = (hash || {}).transform_keys(&:to_s)
|
|
32
|
+
c = (h["cost"] || {}).transform_keys(&:to_s)
|
|
33
|
+
new(input: h.fetch("input", 0).to_i, output: h.fetch("output", 0).to_i,
|
|
34
|
+
cache_read: h.fetch("cache_read", 0).to_i, cache_write: h.fetch("cache_write", 0).to_i,
|
|
35
|
+
cache_write_1h: h.fetch("cache_write_1h", 0).to_i,
|
|
36
|
+
reasoning: h.fetch("reasoning", 0).to_i,
|
|
37
|
+
cost: Cost.new(input: c.fetch("input", 0).to_f, output: c.fetch("output", 0).to_f,
|
|
38
|
+
cache_read: c.fetch("cache_read", 0).to_f,
|
|
39
|
+
cache_write: c.fetch("cache_write", 0).to_f,
|
|
40
|
+
total: c.fetch("total", 0).to_f))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def total_tokens = input + output + cache_read + cache_write
|
|
44
|
+
|
|
45
|
+
# A copy with cost computed from per-million-token rates. The 1h cache-write
|
|
46
|
+
# slice bills at the :cache_write_1h rate when given, else at twice the
|
|
47
|
+
# input rate, matching extended-retention pricing.
|
|
48
|
+
def with_cost(rates)
|
|
49
|
+
long = [cache_write_1h, cache_write].min
|
|
50
|
+
short = cache_write - long
|
|
51
|
+
computed = {
|
|
52
|
+
input: rate(rates, :input) * input,
|
|
53
|
+
output: rate(rates, :output) * output,
|
|
54
|
+
cache_read: rate(rates, :cache_read) * cache_read,
|
|
55
|
+
cache_write: (rate(rates, :cache_write) * short) + (long_write_rate(rates) * long)
|
|
56
|
+
}
|
|
57
|
+
with(cost: Cost.new(**computed, total: computed.values.sum))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def +(other)
|
|
61
|
+
self.class.new(input: input + other.input, output: output + other.output,
|
|
62
|
+
cache_read: cache_read + other.cache_read,
|
|
63
|
+
cache_write: cache_write + other.cache_write,
|
|
64
|
+
cache_write_1h: cache_write_1h + other.cache_write_1h,
|
|
65
|
+
reasoning: reasoning + other.reasoning,
|
|
66
|
+
cost: cost + other.cost)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_h = super.merge(cost: cost.to_h)
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def rate(rates, key) = rates.fetch(key, 0).to_f / 1_000_000
|
|
74
|
+
|
|
75
|
+
def long_write_rate(rates)
|
|
76
|
+
rates.key?(:cache_write_1h) ? rate(rates, :cache_write_1h) : rate(rates, :input) * 2
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/mistri/version.rb
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
module Workspace
|
|
5
|
+
# Documents in the host's own database, through a model class the host
|
|
6
|
+
# supplies, optionally scoped (per session, per tenant). Not auto-required;
|
|
7
|
+
# load it with require "mistri/workspace/active_record".
|
|
8
|
+
#
|
|
9
|
+
# The model needs a path column and a content column, unique on path
|
|
10
|
+
# within the scope. A migration to copy:
|
|
11
|
+
#
|
|
12
|
+
# create_table :mistri_documents do |t|
|
|
13
|
+
# t.string :session_id, null: false
|
|
14
|
+
# t.string :path, null: false, limit: 512
|
|
15
|
+
# t.text :content, size: :medium, null: false
|
|
16
|
+
# t.timestamps
|
|
17
|
+
# end
|
|
18
|
+
# add_index :mistri_documents, [:session_id, :path], unique: true
|
|
19
|
+
class ActiveRecord
|
|
20
|
+
def initialize(model, scope: {})
|
|
21
|
+
@model = model
|
|
22
|
+
@scope = scope
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def read(path)
|
|
26
|
+
@model.where(**@scope, path: path.to_s).pick(:content)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def write(path, content)
|
|
30
|
+
record = @model.find_or_initialize_by(**@scope, path: path.to_s)
|
|
31
|
+
record.content = content.to_s
|
|
32
|
+
record.save!
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def delete(path)
|
|
37
|
+
@model.where(**@scope, path: path.to_s).delete_all
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def list(prefix = nil)
|
|
42
|
+
paths = @model.where(**@scope).pluck(:path).sort
|
|
43
|
+
prefix ? paths.select { |p| p.start_with?(prefix.to_s) } : paths
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Mistri
|
|
6
|
+
module Workspace
|
|
7
|
+
# Documents as files under one root. Every path resolves inside the root
|
|
8
|
+
# or raises, so a model-supplied "../../etc/passwd" cannot escape.
|
|
9
|
+
class Directory
|
|
10
|
+
def initialize(root)
|
|
11
|
+
@root = File.expand_path(root)
|
|
12
|
+
FileUtils.mkdir_p(@root)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def read(path)
|
|
16
|
+
full = resolve(path)
|
|
17
|
+
File.exist?(full) ? File.read(full) : nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def write(path, content)
|
|
21
|
+
full = resolve(path)
|
|
22
|
+
FileUtils.mkdir_p(File.dirname(full))
|
|
23
|
+
File.write(full, content.to_s)
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def delete(path)
|
|
28
|
+
full = resolve(path)
|
|
29
|
+
FileUtils.rm_f(full)
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def list(prefix = nil)
|
|
34
|
+
base = @root.length + 1
|
|
35
|
+
paths = Dir.glob(File.join(@root, "**", "*")).select { |f| File.file?(f) }
|
|
36
|
+
.map { |f| f[base..] }.sort
|
|
37
|
+
prefix ? paths.select { |p| p.start_with?(prefix.to_s) } : paths
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def resolve(path)
|
|
43
|
+
full = File.expand_path(path.to_s, @root)
|
|
44
|
+
unless full == @root || full.start_with?("#{@root}#{File::SEPARATOR}")
|
|
45
|
+
raise SchemaError, "path escapes the workspace: #{path}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
full
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# The document store agents work in. A workspace maps paths to text and can
|
|
5
|
+
# live anywhere: memory for tests and ephemeral runs, a directory when disk
|
|
6
|
+
# exists, the host's database when it does not. The file tools bind to this
|
|
7
|
+
# port, so fuzzy-editing a MySQL row works exactly like editing a file.
|
|
8
|
+
#
|
|
9
|
+
# A backend implements read(path) -> String or nil, write(path, content),
|
|
10
|
+
# delete(path), and list(prefix = nil) -> [paths].
|
|
11
|
+
module Workspace
|
|
12
|
+
class Memory
|
|
13
|
+
def initialize
|
|
14
|
+
@documents = {}
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def read(path)
|
|
19
|
+
@mutex.synchronize { @documents[path.to_s] }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def write(path, content)
|
|
23
|
+
@mutex.synchronize { @documents[path.to_s] = content.to_s.dup.freeze }
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def delete(path)
|
|
28
|
+
@mutex.synchronize { @documents.delete(path.to_s) }
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def list(prefix = nil)
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
keys = @documents.keys.sort
|
|
35
|
+
prefix ? keys.select { |key| key.start_with?(prefix.to_s) } : keys
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
module Workspace
|
|
5
|
+
# One document living wherever the host says: a column on a record, a
|
|
6
|
+
# cache key, anything readable and writable. This is the shape of an
|
|
7
|
+
# agent that edits one page:
|
|
8
|
+
#
|
|
9
|
+
# workspace = Mistri::Workspace::Single.new(
|
|
10
|
+
# path: "page.html",
|
|
11
|
+
# read: -> { page.reload.draft_html },
|
|
12
|
+
# write: ->(html) { page.update!(draft_html: html) }
|
|
13
|
+
# )
|
|
14
|
+
#
|
|
15
|
+
# The document tools then read and edit that column like any document.
|
|
16
|
+
class Single
|
|
17
|
+
def initialize(read:, write:, path: "document")
|
|
18
|
+
@path = path.to_s
|
|
19
|
+
@read = read
|
|
20
|
+
@write = write
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def read(path)
|
|
24
|
+
path.to_s == @path ? @read.call : nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def write(path, content)
|
|
28
|
+
raise SchemaError, "this workspace holds only #{@path.inspect}" unless path.to_s == @path
|
|
29
|
+
|
|
30
|
+
@write.call(content.to_s)
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def delete(path)
|
|
35
|
+
if path.to_s == @path
|
|
36
|
+
raise SchemaError,
|
|
37
|
+
"#{@path.inspect} cannot be deleted, only rewritten"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def list(prefix = nil)
|
|
44
|
+
prefix.nil? || @path.start_with?(prefix.to_s) ? [@path] : []
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/mistri.rb
CHANGED
|
@@ -1,7 +1,96 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "mistri/version"
|
|
4
|
+
require_relative "mistri/errors"
|
|
5
|
+
require_relative "mistri/stop_reason"
|
|
6
|
+
require_relative "mistri/usage"
|
|
7
|
+
require_relative "mistri/models"
|
|
8
|
+
require_relative "mistri/tool_call"
|
|
9
|
+
require_relative "mistri/content"
|
|
10
|
+
require_relative "mistri/message"
|
|
11
|
+
require_relative "mistri/event"
|
|
12
|
+
require_relative "mistri/abort_signal"
|
|
13
|
+
require_relative "mistri/sse"
|
|
14
|
+
require_relative "mistri/partial_json"
|
|
15
|
+
require_relative "mistri/transport"
|
|
16
|
+
require_relative "mistri/schema"
|
|
17
|
+
require_relative "mistri/edit"
|
|
18
|
+
require_relative "mistri/tool_context"
|
|
19
|
+
require_relative "mistri/tool_result"
|
|
20
|
+
require_relative "mistri/tool"
|
|
21
|
+
require_relative "mistri/workspace/memory"
|
|
22
|
+
require_relative "mistri/workspace/directory"
|
|
23
|
+
require_relative "mistri/workspace/single"
|
|
24
|
+
require_relative "mistri/memory"
|
|
25
|
+
require_relative "mistri/tools"
|
|
26
|
+
require_relative "mistri/skill"
|
|
27
|
+
require_relative "mistri/skills"
|
|
28
|
+
require_relative "mistri/tool_executor"
|
|
29
|
+
require_relative "mistri/budget"
|
|
30
|
+
require_relative "mistri/retry_policy"
|
|
31
|
+
require_relative "mistri/reminder"
|
|
32
|
+
require_relative "mistri/compaction"
|
|
33
|
+
require_relative "mistri/stores/memory"
|
|
34
|
+
require_relative "mistri/stores/jsonl"
|
|
35
|
+
require_relative "mistri/session"
|
|
36
|
+
require_relative "mistri/compactor"
|
|
37
|
+
require_relative "mistri/result"
|
|
38
|
+
require_relative "mistri/agent"
|
|
39
|
+
require_relative "mistri/sub_agent"
|
|
40
|
+
require_relative "mistri/mcp"
|
|
41
|
+
require_relative "mistri/sinks/action_cable"
|
|
42
|
+
require_relative "mistri/sinks/sse"
|
|
43
|
+
require_relative "mistri/sinks/coalesced"
|
|
44
|
+
require_relative "mistri/providers/fake"
|
|
45
|
+
require_relative "mistri/providers/anthropic"
|
|
46
|
+
require_relative "mistri/providers/openai"
|
|
47
|
+
require_relative "mistri/providers/gemini"
|
|
4
48
|
|
|
5
49
|
# Mistri (مستری): the fixer. An agent harness for Ruby applications.
|
|
6
50
|
module Mistri
|
|
51
|
+
PROVIDERS = { anthropic: Providers::Anthropic, openai: Providers::OpenAI,
|
|
52
|
+
gemini: Providers::Gemini }.freeze
|
|
53
|
+
API_KEY_ENV = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY",
|
|
54
|
+
gemini: "GEMINI_API_KEY" }.freeze
|
|
55
|
+
|
|
56
|
+
module_function
|
|
57
|
+
|
|
58
|
+
# Build a provider for a model, inferring which one from the model id and
|
|
59
|
+
# reading its key from the environment unless one is passed.
|
|
60
|
+
#
|
|
61
|
+
# Mistri.provider("claude-opus-4-8")
|
|
62
|
+
# Mistri.provider("gpt-5.5", api_key: key, reasoning: { effort: "high" })
|
|
63
|
+
def provider(model, api_key: nil, **)
|
|
64
|
+
name = provider_name(model)
|
|
65
|
+
klass = PROVIDERS.fetch(name) { raise ConfigurationError, "no provider for #{model.inspect}" }
|
|
66
|
+
key = api_key || ENV.fetch(API_KEY_ENV.fetch(name), nil)
|
|
67
|
+
raise ConfigurationError, "no API key for #{name}" if key.to_s.empty?
|
|
68
|
+
|
|
69
|
+
klass.new(api_key: key, model: model, **)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Build an agent for a model in one call: infers and constructs the provider,
|
|
73
|
+
# then wraps it in the loop.
|
|
74
|
+
#
|
|
75
|
+
# agent = Mistri.agent("claude-opus-4-8", tools: [weather], system: "Be brief.")
|
|
76
|
+
# agent.run("Weather in Lahore?") { |event| ... }
|
|
77
|
+
def agent(model, api_key: nil, provider_options: {}, **agent_options)
|
|
78
|
+
built = provider(model, api_key: api_key, **provider_options)
|
|
79
|
+
Agent.new(provider: built, **agent_options)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Catalogued models infer directly; unknown ids fall back to the id prefix,
|
|
83
|
+
# so a brand-new model works before it is catalogued.
|
|
84
|
+
def provider_name(model)
|
|
85
|
+
Models.find(model)&.provider || infer_provider_name(model)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def infer_provider_name(model)
|
|
89
|
+
case model.to_s
|
|
90
|
+
when /\Aclaude/ then :anthropic
|
|
91
|
+
when /\A(gpt|o\d|chatgpt)/ then :openai
|
|
92
|
+
when /\Agemini/ then :gemini
|
|
93
|
+
else raise ConfigurationError, "cannot infer a provider from #{model.inspect}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
7
96
|
end
|