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,13 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../workspace"
|
|
4
|
+
|
|
3
5
|
module Mistri
|
|
4
6
|
module Workspace
|
|
5
7
|
# Documents in the host's own database, through a model class the host
|
|
6
8
|
# supplies, optionally scoped (per session, per tenant). Not auto-required;
|
|
7
9
|
# load it with require "mistri/workspace/active_record".
|
|
8
10
|
#
|
|
9
|
-
# The model needs a
|
|
10
|
-
#
|
|
11
|
+
# The model needs a non-null primary key plus non-null path, content, and
|
|
12
|
+
# scope columns, with a unique index whose columns are exactly the scope
|
|
13
|
+
# plus path. Atomic edits use that index plus a short row-locking
|
|
14
|
+
# transaction; no lock_version column is required. A migration to copy:
|
|
11
15
|
#
|
|
12
16
|
# create_table :mistri_documents do |t|
|
|
13
17
|
# t.string :session_id, null: false
|
|
@@ -17,9 +21,24 @@ module Mistri
|
|
|
17
21
|
# end
|
|
18
22
|
# add_index :mistri_documents, [:session_id, :path], unique: true
|
|
19
23
|
class ActiveRecord
|
|
24
|
+
MYSQL_ADAPTERS = %w[Mysql2 Trilogy].freeze
|
|
25
|
+
private_constant :MYSQL_ADAPTERS
|
|
26
|
+
|
|
20
27
|
def initialize(model, scope: {})
|
|
28
|
+
raise ArgumentError, "scope must be a Hash" unless scope.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
names = scope.keys.map(&:to_s)
|
|
31
|
+
if names.uniq.length != names.length
|
|
32
|
+
raise ArgumentError, "scope keys must name distinct columns"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
reserved = names & %w[path content]
|
|
36
|
+
unless reserved.empty?
|
|
37
|
+
raise ArgumentError, "scope cannot contain #{reserved.map(&:inspect).join(" or ")}"
|
|
38
|
+
end
|
|
39
|
+
|
|
21
40
|
@model = model
|
|
22
|
-
@scope = scope
|
|
41
|
+
@scope = own_scope(scope)
|
|
23
42
|
end
|
|
24
43
|
|
|
25
44
|
def read(path)
|
|
@@ -33,6 +52,38 @@ module Mistri
|
|
|
33
52
|
nil
|
|
34
53
|
end
|
|
35
54
|
|
|
55
|
+
def atomic_writes?
|
|
56
|
+
return @atomic_writes if defined?(@atomic_writes)
|
|
57
|
+
|
|
58
|
+
@atomic_writes = atomic_storage?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def snapshot(path)
|
|
62
|
+
verify_atomic_context!
|
|
63
|
+
content = relation(path).pick(:content)
|
|
64
|
+
content.nil? ? nil : Snapshot.for(content.to_s)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def compare_and_write(path, content, expected_revision:)
|
|
68
|
+
verify_atomic_context!
|
|
69
|
+
key = path.to_s
|
|
70
|
+
return create_if_absent(key, content) if expected_revision.nil?
|
|
71
|
+
|
|
72
|
+
@model.transaction do
|
|
73
|
+
record = @model.lock.find_by(**@scope, path: key)
|
|
74
|
+
actual = record && Snapshot.for(record.content.to_s).revision
|
|
75
|
+
unless actual == expected_revision
|
|
76
|
+
raise WorkspaceConflictError.new(
|
|
77
|
+
key, expected_revision:, actual_revision: actual
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
record.content = content.to_s
|
|
82
|
+
record.save!
|
|
83
|
+
committed_snapshot(record, key)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
36
87
|
def delete(path)
|
|
37
88
|
@model.where(**@scope, path: path.to_s).delete_all
|
|
38
89
|
nil
|
|
@@ -42,6 +93,135 @@ module Mistri
|
|
|
42
93
|
paths = @model.where(**@scope).pluck(:path).sort
|
|
43
94
|
prefix ? paths.select { |p| p.start_with?(prefix.to_s) } : paths
|
|
44
95
|
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def verify_atomic_context!
|
|
100
|
+
unless atomic_writes?
|
|
101
|
+
adapter = @model.connection.adapter_name
|
|
102
|
+
raise ConfigurationError,
|
|
103
|
+
"atomic Active Record workspace writes require PostgreSQL or InnoDB, " \
|
|
104
|
+
"a matching non-null primary key, non-null scope/path/content columns, " \
|
|
105
|
+
"and an exact unique scope/path key; #{@model.table_name.inspect} on " \
|
|
106
|
+
"#{adapter} does not qualify"
|
|
107
|
+
end
|
|
108
|
+
return unless @model.connection.transaction_open?
|
|
109
|
+
|
|
110
|
+
raise ConfigurationError,
|
|
111
|
+
"atomic Active Record workspace operations cannot join an open transaction"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def relation(path) = @model.where(**@scope, path: path.to_s)
|
|
115
|
+
|
|
116
|
+
def create_if_absent(path, content)
|
|
117
|
+
@model.transaction do
|
|
118
|
+
record = @model.create!(**@scope, path:, content: content.to_s)
|
|
119
|
+
committed_snapshot(record, path)
|
|
120
|
+
end
|
|
121
|
+
rescue ::ActiveRecord::RecordNotUnique
|
|
122
|
+
raise unless relation(path).exists?
|
|
123
|
+
|
|
124
|
+
actual = snapshot(path)&.revision
|
|
125
|
+
raise WorkspaceConflictError.new(path, actual_revision: actual)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def atomic_storage?
|
|
129
|
+
adapter = @model.connection.adapter_name
|
|
130
|
+
supported = adapter == "PostgreSQL" ||
|
|
131
|
+
(MYSQL_ADAPTERS.include?(adapter) && innodb_table?)
|
|
132
|
+
supported && atomic_schema?
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def innodb_table?
|
|
136
|
+
connection = @model.connection
|
|
137
|
+
database, table = mysql_table_coordinates(connection)
|
|
138
|
+
sql = "SELECT ENGINE FROM information_schema.TABLES " \
|
|
139
|
+
"WHERE TABLE_SCHEMA = #{connection.quote(database)} " \
|
|
140
|
+
"AND TABLE_NAME = #{connection.quote(table)}"
|
|
141
|
+
connection.select_value(sql, "SCHEMA")&.casecmp?("InnoDB") == true
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def mysql_table_coordinates(connection)
|
|
145
|
+
names = @model.table_name.to_s.split(".", 2)
|
|
146
|
+
return names if names.length == 2
|
|
147
|
+
|
|
148
|
+
[connection.current_database, names.first]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def atomic_schema?
|
|
152
|
+
connection = @model.connection
|
|
153
|
+
columns = connection.columns(@model.table_name).to_h { |column| [column.name, column] }
|
|
154
|
+
primary_columns = Array(@model.primary_key).compact.map(&:to_s)
|
|
155
|
+
catalog_primary = Array(connection.primary_keys(@model.table_name)).map(&:to_s)
|
|
156
|
+
return false unless primary_columns.sort == catalog_primary.sort
|
|
157
|
+
return false if primary_columns.empty? || !singular_scope?
|
|
158
|
+
|
|
159
|
+
required_columns = (@scope.keys.map(&:to_s) + %w[path content]).uniq
|
|
160
|
+
required_columns.concat(primary_columns).uniq!
|
|
161
|
+
return false unless non_null_columns?(columns, required_columns)
|
|
162
|
+
|
|
163
|
+
identity = (@scope.keys.map(&:to_s) + ["path"]).uniq.sort
|
|
164
|
+
return true if catalog_primary.sort == identity
|
|
165
|
+
|
|
166
|
+
exact_unique_identity?(connection, identity)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def exact_unique_identity?(connection, identity)
|
|
170
|
+
connection.indexes(@model.table_name).any? do |index|
|
|
171
|
+
valid = !index.respond_to?(:valid?) || index.valid?
|
|
172
|
+
columns = Array(index.columns).map(&:to_s).sort
|
|
173
|
+
index.unique && valid && !index.where && columns == identity
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def non_null_columns?(columns, required)
|
|
178
|
+
required.all? do |name|
|
|
179
|
+
column = columns[name]
|
|
180
|
+
column && !column.null
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def committed_snapshot(record, path)
|
|
185
|
+
identity = Array(@model.primary_key).compact.to_h do |column|
|
|
186
|
+
attributes = record.attributes
|
|
187
|
+
value = attributes.fetch(column.to_s) { attributes.fetch(column.to_sym) }
|
|
188
|
+
[column.to_sym, value]
|
|
189
|
+
end
|
|
190
|
+
if identity.empty?
|
|
191
|
+
raise ConfigurationError, "atomic Active Record workspace needs a primary key"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
content = @model.where(**identity, **@scope, path:).pick(:content)
|
|
195
|
+
if content.nil?
|
|
196
|
+
raise SchemaError, "document #{path.inspect} changed identity during its write"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
Snapshot.for(content.to_s)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def singular_scope?
|
|
203
|
+
@scope.values.all? do |value|
|
|
204
|
+
case value
|
|
205
|
+
when String, Symbol, Numeric, TrueClass, FalseClass, Time then true
|
|
206
|
+
else defined?(Date) && value.is_a?(Date)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def own_scope(value)
|
|
212
|
+
case value
|
|
213
|
+
when Hash
|
|
214
|
+
value.to_h { |key, item| [own_scope(key), own_scope(item)] }.freeze
|
|
215
|
+
when Array
|
|
216
|
+
value.map { |item| own_scope(item) }.freeze
|
|
217
|
+
when Range
|
|
218
|
+
Range.new(own_scope(value.begin), own_scope(value.end), value.exclude_end?).freeze
|
|
219
|
+
when String
|
|
220
|
+
String.new(value).freeze
|
|
221
|
+
else
|
|
222
|
+
value
|
|
223
|
+
end
|
|
224
|
+
end
|
|
45
225
|
end
|
|
46
226
|
end
|
|
47
227
|
end
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require_relative "../workspace"
|
|
4
5
|
|
|
5
6
|
module Mistri
|
|
6
7
|
module Workspace
|
|
7
|
-
# Documents as files under
|
|
8
|
-
#
|
|
8
|
+
# Documents as ordinary files under a host-controlled root. Model-supplied
|
|
9
|
+
# paths cannot escape lexically or traverse an existing symlink. The host
|
|
10
|
+
# must keep the root and tree stable for the instance's lifetime; this is
|
|
11
|
+
# not an OS sandbox against concurrent filesystem mutation.
|
|
9
12
|
class Directory
|
|
10
13
|
def initialize(root)
|
|
11
|
-
|
|
12
|
-
FileUtils.mkdir_p(
|
|
14
|
+
expanded = File.expand_path(root)
|
|
15
|
+
FileUtils.mkdir_p(expanded)
|
|
16
|
+
@root = File.realpath(expanded)
|
|
17
|
+
@root_prefix = @root.end_with?(File::SEPARATOR) ? @root : "#{@root}#{File::SEPARATOR}"
|
|
13
18
|
end
|
|
14
19
|
|
|
15
20
|
def read(path)
|
|
@@ -20,6 +25,7 @@ module Mistri
|
|
|
20
25
|
def write(path, content)
|
|
21
26
|
full = resolve(path)
|
|
22
27
|
FileUtils.mkdir_p(File.dirname(full))
|
|
28
|
+
full = resolve(path)
|
|
23
29
|
File.write(full, content.to_s)
|
|
24
30
|
nil
|
|
25
31
|
end
|
|
@@ -31,9 +37,13 @@ module Mistri
|
|
|
31
37
|
end
|
|
32
38
|
|
|
33
39
|
def list(prefix = nil)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
paths = Dir.glob("**/*", base: @root).filter_map do |path|
|
|
41
|
+
full = File.join(@root, path)
|
|
42
|
+
next if traverses_symlink?(full)
|
|
43
|
+
next unless File.file?(full)
|
|
44
|
+
|
|
45
|
+
path
|
|
46
|
+
end.sort
|
|
37
47
|
prefix ? paths.select { |p| p.start_with?(prefix.to_s) } : paths
|
|
38
48
|
end
|
|
39
49
|
|
|
@@ -41,12 +51,22 @@ module Mistri
|
|
|
41
51
|
|
|
42
52
|
def resolve(path)
|
|
43
53
|
full = File.expand_path(path.to_s, @root)
|
|
44
|
-
unless full == @root || full.start_with?(
|
|
54
|
+
unless full == @root || full.start_with?(@root_prefix)
|
|
45
55
|
raise SchemaError, "path escapes the workspace: #{path}"
|
|
46
56
|
end
|
|
57
|
+
raise SchemaError, "path traverses a symlink: #{path}" if traverses_symlink?(full)
|
|
47
58
|
|
|
48
59
|
full
|
|
49
60
|
end
|
|
61
|
+
|
|
62
|
+
def traverses_symlink?(full)
|
|
63
|
+
current = @root
|
|
64
|
+
relative = full.delete_prefix(@root).delete_prefix(File::SEPARATOR)
|
|
65
|
+
relative.split(File::SEPARATOR).any? do |part|
|
|
66
|
+
current = File.join(current, part)
|
|
67
|
+
File.symlink?(current)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
50
70
|
end
|
|
51
71
|
end
|
|
52
72
|
end
|
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../workspace"
|
|
4
|
+
|
|
3
5
|
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
6
|
module Workspace
|
|
7
|
+
# A mutex-protected ephemeral document map with atomic conditional writes.
|
|
12
8
|
class Memory
|
|
13
9
|
def initialize
|
|
14
10
|
@documents = {}
|
|
@@ -16,14 +12,39 @@ module Mistri
|
|
|
16
12
|
end
|
|
17
13
|
|
|
18
14
|
def read(path)
|
|
19
|
-
@mutex.synchronize { @documents[path.to_s] }
|
|
15
|
+
@mutex.synchronize { @documents[path.to_s]&.dup }
|
|
20
16
|
end
|
|
21
17
|
|
|
22
18
|
def write(path, content)
|
|
23
|
-
@mutex.synchronize { @documents[path.to_s] = content
|
|
19
|
+
@mutex.synchronize { @documents[path.to_s] = own(content) }
|
|
24
20
|
nil
|
|
25
21
|
end
|
|
26
22
|
|
|
23
|
+
def atomic_writes? = true
|
|
24
|
+
|
|
25
|
+
def snapshot(path)
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
content = @documents[path.to_s]
|
|
28
|
+
content && Snapshot.for(content)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def compare_and_write(path, content, expected_revision:)
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
key = path.to_s
|
|
35
|
+
current = @documents[key]
|
|
36
|
+
actual = current && Snapshot.for(current).revision
|
|
37
|
+
unless actual == expected_revision
|
|
38
|
+
raise WorkspaceConflictError.new(
|
|
39
|
+
key, expected_revision:, actual_revision: actual
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
@documents[key] = own(content)
|
|
44
|
+
Snapshot.for(@documents[key])
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
27
48
|
def delete(path)
|
|
28
49
|
@mutex.synchronize { @documents.delete(path.to_s) }
|
|
29
50
|
nil
|
|
@@ -35,6 +56,10 @@ module Mistri
|
|
|
35
56
|
prefix ? keys.select { |key| key.start_with?(prefix.to_s) } : keys
|
|
36
57
|
end
|
|
37
58
|
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def own(content) = String.new(content.to_s).freeze
|
|
38
63
|
end
|
|
39
64
|
end
|
|
40
65
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../workspace"
|
|
4
|
+
|
|
3
5
|
module Mistri
|
|
4
6
|
module Workspace
|
|
5
7
|
# One document living wherever the host says: a column on a record, a
|
|
@@ -9,15 +11,26 @@ module Mistri
|
|
|
9
11
|
# workspace = Mistri::Workspace::Single.new(
|
|
10
12
|
# path: "page.html",
|
|
11
13
|
# read: -> { page.reload.draft_html },
|
|
12
|
-
# write: ->(html) { page.update!(draft_html: html) }
|
|
14
|
+
# write: ->(html) { page.update!(draft_html: html) },
|
|
15
|
+
# synchronize: lambda do |&operation|
|
|
16
|
+
# raise "outer transaction" if page.class.connection.transaction_open?
|
|
17
|
+
# page.with_lock(&operation)
|
|
18
|
+
# end
|
|
13
19
|
# )
|
|
14
20
|
#
|
|
15
21
|
# The document tools then read and edit that column like any document.
|
|
22
|
+
# synchronize: is optional; without it, the legacy read/write port cannot
|
|
23
|
+
# protect an edit from another process writing between those two calls.
|
|
16
24
|
class Single
|
|
17
|
-
def initialize(read:, write:, path: "document")
|
|
18
|
-
|
|
25
|
+
def initialize(read:, write:, path: "document", synchronize: nil)
|
|
26
|
+
unless synchronize.nil? || synchronize.respond_to?(:call)
|
|
27
|
+
raise ArgumentError, "synchronize must be callable"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
@path = String.new(path.to_s).freeze
|
|
19
31
|
@read = read
|
|
20
32
|
@write = write
|
|
33
|
+
@synchronize = synchronize
|
|
21
34
|
end
|
|
22
35
|
|
|
23
36
|
def read(path)
|
|
@@ -25,12 +38,44 @@ module Mistri
|
|
|
25
38
|
end
|
|
26
39
|
|
|
27
40
|
def write(path, content)
|
|
28
|
-
|
|
41
|
+
verify_path!(path)
|
|
29
42
|
|
|
30
|
-
@write.call(content.to_s)
|
|
43
|
+
synchronize { @write.call(content.to_s) }
|
|
31
44
|
nil
|
|
32
45
|
end
|
|
33
46
|
|
|
47
|
+
def atomic_writes? = !@synchronize.nil?
|
|
48
|
+
|
|
49
|
+
def snapshot(path)
|
|
50
|
+
return nil unless path.to_s == @path
|
|
51
|
+
|
|
52
|
+
content = @read.call
|
|
53
|
+
content.nil? ? nil : Snapshot.for(content.to_s)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def compare_and_write(path, content, expected_revision:)
|
|
57
|
+
verify_path!(path)
|
|
58
|
+
unless atomic_writes?
|
|
59
|
+
raise ConfigurationError, "Single needs synchronize: for atomic writes"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
synchronize do
|
|
63
|
+
current = @read.call
|
|
64
|
+
actual = current.nil? ? nil : Snapshot.for(current.to_s).revision
|
|
65
|
+
unless actual == expected_revision
|
|
66
|
+
raise WorkspaceConflictError.new(
|
|
67
|
+
@path, expected_revision:, actual_revision: actual
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
@write.call(content.to_s)
|
|
72
|
+
committed = @read.call
|
|
73
|
+
raise SchemaError, "#{@path.inspect} disappeared after write" if committed.nil?
|
|
74
|
+
|
|
75
|
+
Snapshot.for(committed.to_s)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
34
79
|
def delete(path)
|
|
35
80
|
if path.to_s == @path
|
|
36
81
|
raise SchemaError,
|
|
@@ -43,6 +88,18 @@ module Mistri
|
|
|
43
88
|
def list(prefix = nil)
|
|
44
89
|
prefix.nil? || @path.start_with?(prefix.to_s) ? [@path] : []
|
|
45
90
|
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def verify_path!(path)
|
|
95
|
+
return if path.to_s == @path
|
|
96
|
+
|
|
97
|
+
raise SchemaError, "this workspace holds only #{@path.inspect}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def synchronize(&operation)
|
|
101
|
+
@synchronize ? @synchronize.call(&operation) : operation.call
|
|
102
|
+
end
|
|
46
103
|
end
|
|
47
104
|
end
|
|
48
105
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
module Mistri
|
|
7
|
+
# The host-neutral document boundary. Every workspace supports ordinary
|
|
8
|
+
# read/write/delete/list; atomic_writes? opts into snapshot and conditional
|
|
9
|
+
# write semantics that remain correct across processes when its backend does.
|
|
10
|
+
module Workspace
|
|
11
|
+
MAX_REVISION_BYTES = 256
|
|
12
|
+
private_constant :MAX_REVISION_BYTES
|
|
13
|
+
|
|
14
|
+
# One immutable document representation and the opaque token that binds a
|
|
15
|
+
# later conditional write to these exact bytes.
|
|
16
|
+
Snapshot = Data.define(:content, :revision) do
|
|
17
|
+
def initialize(content:, revision:)
|
|
18
|
+
raise ArgumentError, "snapshot content must be a String" unless content.is_a?(String)
|
|
19
|
+
raise ArgumentError, "snapshot revision must be a String" unless revision.is_a?(String)
|
|
20
|
+
|
|
21
|
+
owned_content = String.new(content)
|
|
22
|
+
owned_revision = String.new(revision)
|
|
23
|
+
unless !owned_revision.empty? && owned_revision.bytesize <= MAX_REVISION_BYTES
|
|
24
|
+
raise ArgumentError,
|
|
25
|
+
"snapshot revision must be 1-#{MAX_REVISION_BYTES} bytes"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
super(content: owned_content.freeze, revision: owned_revision.freeze)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.for(content)
|
|
32
|
+
raise ArgumentError, "snapshot content must be a String" unless content.is_a?(String)
|
|
33
|
+
|
|
34
|
+
owned = String.new(content)
|
|
35
|
+
new(content: owned, revision: Digest::SHA256.hexdigest(owned.b))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/mistri.rb
CHANGED
|
@@ -5,19 +5,23 @@ require_relative "mistri/errors"
|
|
|
5
5
|
require_relative "mistri/stop_reason"
|
|
6
6
|
require_relative "mistri/usage"
|
|
7
7
|
require_relative "mistri/models"
|
|
8
|
+
require_relative "mistri/tool_arguments"
|
|
8
9
|
require_relative "mistri/tool_call"
|
|
9
10
|
require_relative "mistri/content"
|
|
10
11
|
require_relative "mistri/message"
|
|
11
12
|
require_relative "mistri/event"
|
|
13
|
+
require_relative "mistri/event_delivery"
|
|
12
14
|
require_relative "mistri/abort_signal"
|
|
13
15
|
require_relative "mistri/sse"
|
|
14
16
|
require_relative "mistri/partial_json"
|
|
15
17
|
require_relative "mistri/transport"
|
|
16
18
|
require_relative "mistri/schema"
|
|
19
|
+
require_relative "mistri/task_output"
|
|
17
20
|
require_relative "mistri/edit"
|
|
18
21
|
require_relative "mistri/tool_context"
|
|
19
22
|
require_relative "mistri/tool_result"
|
|
20
23
|
require_relative "mistri/tool"
|
|
24
|
+
require_relative "mistri/workspace"
|
|
21
25
|
require_relative "mistri/workspace/memory"
|
|
22
26
|
require_relative "mistri/workspace/directory"
|
|
23
27
|
require_relative "mistri/workspace/single"
|
|
@@ -34,10 +38,15 @@ require_relative "mistri/compaction"
|
|
|
34
38
|
require_relative "mistri/stores/memory"
|
|
35
39
|
require_relative "mistri/stores/jsonl"
|
|
36
40
|
require_relative "mistri/session"
|
|
41
|
+
require_relative "mistri/child"
|
|
42
|
+
require_relative "mistri/locks"
|
|
43
|
+
require_relative "mistri/console"
|
|
44
|
+
require_relative "mistri/dispatchers"
|
|
37
45
|
require_relative "mistri/compactor"
|
|
38
46
|
require_relative "mistri/result"
|
|
39
47
|
require_relative "mistri/agent"
|
|
40
48
|
require_relative "mistri/sub_agent"
|
|
49
|
+
require_relative "mistri/spawner"
|
|
41
50
|
require_relative "mistri/mcp"
|
|
42
51
|
require_relative "mistri/sinks/action_cable"
|
|
43
52
|
require_relative "mistri/sinks/sse"
|
|
@@ -49,18 +58,25 @@ require_relative "mistri/providers/gemini"
|
|
|
49
58
|
|
|
50
59
|
# Mistri (مستری): the fixer. An agent harness for Ruby applications.
|
|
51
60
|
module Mistri
|
|
61
|
+
private_constant :ToolArguments
|
|
62
|
+
|
|
52
63
|
PROVIDERS = { anthropic: Providers::Anthropic, openai: Providers::OpenAI,
|
|
53
64
|
gemini: Providers::Gemini }.freeze
|
|
54
65
|
API_KEY_ENV = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY",
|
|
55
66
|
gemini: "GEMINI_API_KEY" }.freeze
|
|
56
67
|
|
|
68
|
+
# The configured lock adapter, nil until a host sets one. See Locks.
|
|
69
|
+
class << self
|
|
70
|
+
attr_accessor :locks
|
|
71
|
+
end
|
|
72
|
+
|
|
57
73
|
module_function
|
|
58
74
|
|
|
59
75
|
# Build a provider for a model, inferring which one from the model id and
|
|
60
76
|
# reading its key from the environment unless one is passed.
|
|
61
77
|
#
|
|
62
78
|
# Mistri.provider("claude-opus-4-8")
|
|
63
|
-
# Mistri.provider("gpt-5.
|
|
79
|
+
# Mistri.provider("gpt-5.6", api_key: key, reasoning: { effort: "high" })
|
|
64
80
|
def provider(model, api_key: nil, **)
|
|
65
81
|
name = provider_name(model)
|
|
66
82
|
klass = PROVIDERS.fetch(name) { raise ConfigurationError, "no provider for #{model.inspect}" }
|
data/mistri.gemspec
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/mistri/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "mistri"
|
|
7
|
+
spec.version = Mistri::VERSION
|
|
8
|
+
spec.authors = ["Muhammad Ahmed Cheema"]
|
|
9
|
+
spec.summary = "The agent harness for Ruby applications."
|
|
10
|
+
spec.description = "Mistri (مستری) is the fixer: an agent harness that lives inside your " \
|
|
11
|
+
"app. Durable sessions in your own store, streaming, tools, " \
|
|
12
|
+
"fire-and-forget human approval, steering, compaction, structured " \
|
|
13
|
+
"output, skills, and sub-agents, across Anthropic, OpenAI, and " \
|
|
14
|
+
"Gemini, with zero runtime dependencies."
|
|
15
|
+
spec.homepage = "https://mistri.sh"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
spec.required_ruby_version = ">= 3.2"
|
|
18
|
+
|
|
19
|
+
spec.metadata["source_code_uri"] = "https://github.com/mcheemaa/mistri"
|
|
20
|
+
spec.metadata["changelog_uri"] = "https://github.com/mcheemaa/mistri/blob/main/CHANGELOG.md"
|
|
21
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
22
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
23
|
+
spec.metadata["documentation_uri"] =
|
|
24
|
+
"https://github.com/mcheemaa/mistri/blob/v#{spec.version}/docs/README.md"
|
|
25
|
+
spec.metadata["bug_tracker_uri"] = "https://github.com/mcheemaa/mistri/issues"
|
|
26
|
+
|
|
27
|
+
# The .tt generator templates must ship, or rails g mistri:install breaks.
|
|
28
|
+
public_files = Dir["lib/**/*.{rb,tt}", "docs/**/*.md", "assets/**/*", "examples/*.rb"]
|
|
29
|
+
.select { |path| File.file?(path) }
|
|
30
|
+
root_files = %w[CHANGELOG.md CONTRIBUTING.md LICENSE NOTICE README.md SECURITY.md
|
|
31
|
+
UPGRADING.md mistri.gemspec]
|
|
32
|
+
spec.files = (public_files + root_files).sort
|
|
33
|
+
spec.require_paths = ["lib"]
|
|
34
|
+
end
|