mistri 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +480 -4
  3. data/CONTRIBUTING.md +52 -0
  4. data/README.md +289 -385
  5. data/SECURITY.md +40 -0
  6. data/UPGRADING.md +640 -0
  7. data/assets/logo-animated.svg +30 -0
  8. data/assets/logo-dark.svg +14 -0
  9. data/assets/logo-light.svg +14 -0
  10. data/assets/logo.svg +14 -0
  11. data/assets/social-preview.png +0 -0
  12. data/docs/README.md +87 -0
  13. data/docs/context-and-workspaces.md +378 -0
  14. data/docs/mcp.md +366 -0
  15. data/docs/reliability.md +450 -0
  16. data/docs/sessions.md +295 -0
  17. data/docs/sub-agents.md +401 -0
  18. data/docs/tool-contracts.md +324 -0
  19. data/examples/approval.rb +36 -0
  20. data/examples/browser.rb +27 -0
  21. data/examples/page_editor.rb +31 -0
  22. data/examples/quickstart.rb +21 -0
  23. data/lib/generators/mistri/install/install_generator.rb +7 -3
  24. data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
  25. data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
  26. data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
  27. data/lib/mistri/agent.rb +575 -55
  28. data/lib/mistri/budget.rb +26 -1
  29. data/lib/mistri/child.rb +72 -16
  30. data/lib/mistri/compaction.rb +26 -10
  31. data/lib/mistri/compactor.rb +34 -11
  32. data/lib/mistri/console.rb +28 -7
  33. data/lib/mistri/content.rb +9 -3
  34. data/lib/mistri/dispatchers.rb +14 -12
  35. data/lib/mistri/errors.rb +83 -4
  36. data/lib/mistri/event.rb +24 -8
  37. data/lib/mistri/event_delivery.rb +60 -0
  38. data/lib/mistri/locks.rb +3 -3
  39. data/lib/mistri/mcp/client.rb +74 -19
  40. data/lib/mistri/mcp/egress.rb +216 -0
  41. data/lib/mistri/mcp/oauth.rb +476 -127
  42. data/lib/mistri/mcp/wires.rb +115 -23
  43. data/lib/mistri/mcp.rb +42 -8
  44. data/lib/mistri/message.rb +21 -11
  45. data/lib/mistri/models.rb +160 -22
  46. data/lib/mistri/providers/anthropic/assembler.rb +282 -44
  47. data/lib/mistri/providers/anthropic/serializer.rb +14 -9
  48. data/lib/mistri/providers/anthropic.rb +29 -6
  49. data/lib/mistri/providers/fake.rb +26 -10
  50. data/lib/mistri/providers/gemini/assembler.rb +148 -21
  51. data/lib/mistri/providers/gemini/serializer.rb +78 -9
  52. data/lib/mistri/providers/gemini.rb +31 -5
  53. data/lib/mistri/providers/openai/assembler.rb +337 -60
  54. data/lib/mistri/providers/openai/serializer.rb +13 -12
  55. data/lib/mistri/providers/openai.rb +29 -5
  56. data/lib/mistri/providers/schema_capabilities.rb +214 -0
  57. data/lib/mistri/result.rb +1 -1
  58. data/lib/mistri/schema.rb +893 -75
  59. data/lib/mistri/session.rb +560 -48
  60. data/lib/mistri/sinks/coalesced.rb +17 -10
  61. data/lib/mistri/spawner.rb +111 -61
  62. data/lib/mistri/sse.rb +57 -14
  63. data/lib/mistri/stores/active_record.rb +11 -4
  64. data/lib/mistri/stores/memory.rb +21 -2
  65. data/lib/mistri/sub_agent/execution.rb +81 -0
  66. data/lib/mistri/sub_agent/runtime.rb +297 -0
  67. data/lib/mistri/sub_agent.rb +124 -87
  68. data/lib/mistri/task_output.rb +24 -6
  69. data/lib/mistri/tool.rb +93 -13
  70. data/lib/mistri/tool_arguments.rb +377 -0
  71. data/lib/mistri/tool_call.rb +43 -9
  72. data/lib/mistri/tool_context.rb +4 -2
  73. data/lib/mistri/tool_executor.rb +117 -26
  74. data/lib/mistri/tool_result.rb +15 -10
  75. data/lib/mistri/tools/edit_file.rb +62 -8
  76. data/lib/mistri/tools.rb +41 -4
  77. data/lib/mistri/transport.rb +149 -44
  78. data/lib/mistri/usage.rb +65 -13
  79. data/lib/mistri/version.rb +1 -1
  80. data/lib/mistri/workspace/active_record.rb +190 -5
  81. data/lib/mistri/workspace/directory.rb +28 -8
  82. data/lib/mistri/workspace/memory.rb +34 -9
  83. data/lib/mistri/workspace/single.rb +62 -5
  84. data/lib/mistri/workspace.rb +39 -0
  85. data/lib/mistri.rb +6 -1
  86. data/mistri.gemspec +34 -0
  87. metadata +31 -3
data/lib/mistri/usage.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Mistri
4
6
  # Token accounting for an assistant turn, and the dollar cost of those tokens.
5
7
  #
@@ -9,37 +11,77 @@ module Mistri
9
11
  # of `cache_write` held for an hour, which bills at twice the input rate.
10
12
  class Usage < Data.define(:input, :output, :cache_read, :cache_write,
11
13
  :cache_write_1h, :reasoning, :cost)
14
+ # Dollar amounts plus whether every contributing token had known pricing.
15
+ # Known stays outside the released Data shape so pattern matching and
16
+ # positional construction remain compatible.
12
17
  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)
18
+ def initialize(input:, output:, cache_read:, cache_write:, total:, known: true)
19
+ @known = known == true
20
+ super(input:, output:, cache_read:, cache_write:, total:)
21
+ end
22
+
23
+ def self.zero
24
+ new(input: 0.0, output: 0.0, cache_read: 0.0, cache_write: 0.0,
25
+ total: 0.0, known: true)
26
+ end
27
+
28
+ def self.unknown
29
+ new(input: 0.0, output: 0.0, cache_read: 0.0, cache_write: 0.0,
30
+ total: 0.0, known: false)
31
+ end
32
+
33
+ def known? = defined?(@known) ? @known : true
34
+
35
+ def with(**changes)
36
+ self.class.new(**to_h, **changes)
37
+ end
14
38
 
15
39
  def +(other)
16
40
  self.class.new(input: input + other.input, output: output + other.output,
17
41
  cache_read: cache_read + other.cache_read,
18
42
  cache_write: cache_write + other.cache_write,
19
- total: total + other.total)
43
+ total: total + other.total, known: known? && other.known?)
20
44
  end
45
+
46
+ def ==(other) = super && known? == other.known?
47
+
48
+ alias_method :eql?, :==
49
+
50
+ def hash = [super, known?].hash
51
+
52
+ def to_h = super.merge(known: known?)
53
+
54
+ def _dump(_level) = JSON.generate(to_h)
55
+
56
+ def self._load(payload) = new(**JSON.parse(payload, symbolize_names: true))
21
57
  end
22
58
 
23
59
  def initialize(input: 0, output: 0, cache_read: 0, cache_write: 0,
24
- cache_write_1h: 0, reasoning: 0, cost: Cost.zero)
60
+ cache_write_1h: 0, reasoning: 0, cost: Cost.unknown)
25
61
  super
26
62
  end
27
63
 
28
- def self.zero = new
64
+ def self.zero = new(cost: Cost.zero)
29
65
 
30
66
  def self.from_h(hash)
31
67
  h = (hash || {}).transform_keys(&:to_s)
32
68
  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))
69
+ counts = { input: h.fetch("input", 0).to_i, output: h.fetch("output", 0).to_i,
70
+ cache_read: h.fetch("cache_read", 0).to_i,
71
+ cache_write: h.fetch("cache_write", 0).to_i,
72
+ cache_write_1h: h.fetch("cache_write_1h", 0).to_i,
73
+ reasoning: h.fetch("reasoning", 0).to_i }
74
+ amounts = { input: c.fetch("input", 0).to_f, output: c.fetch("output", 0).to_f,
75
+ cache_read: c.fetch("cache_read", 0).to_f,
76
+ cache_write: c.fetch("cache_write", 0).to_f,
77
+ total: c.fetch("total", 0).to_f }
78
+ known = c.fetch("known", false) == true
79
+ known ||= !c.key?("known") && counts.values.all?(&:zero?) && amounts.values.all?(&:zero?)
80
+ new(**counts, cost: Cost.new(**amounts, known:))
41
81
  end
42
82
 
83
+ def prompt_tokens = input + cache_read + cache_write
84
+
43
85
  def total_tokens = input + output + cache_read + cache_write
44
86
 
45
87
  # A copy with cost computed from per-million-token rates. The 1h cache-write
@@ -54,7 +96,9 @@ module Mistri
54
96
  cache_read: rate(rates, :cache_read) * cache_read,
55
97
  cache_write: (rate(rates, :cache_write) * short) + (long_write_rate(rates) * long)
56
98
  }
57
- with(cost: Cost.new(**computed, total: computed.values.sum))
99
+ with(cost: Cost.new(**computed,
100
+ total: computed.values.sum,
101
+ known: rates_cover?(rates, short, long)))
58
102
  end
59
103
 
60
104
  def +(other)
@@ -75,5 +119,13 @@ module Mistri
75
119
  def long_write_rate(rates)
76
120
  rates.key?(:cache_write_1h) ? rate(rates, :cache_write_1h) : rate(rates, :input) * 2
77
121
  end
122
+
123
+ def rates_cover?(rates, short, long)
124
+ (input.zero? || rates.key?(:input)) &&
125
+ (output.zero? || rates.key?(:output)) &&
126
+ (cache_read.zero? || rates.key?(:cache_read)) &&
127
+ (short.zero? || rates.key?(:cache_write)) &&
128
+ (long.zero? || rates.key?(:cache_write_1h) || rates.key?(:input))
129
+ end
78
130
  end
79
131
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mistri
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.1"
5
5
  end
@@ -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 path column and a content column, unique on path
10
- # within the scope. A migration to copy:
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
@@ -16,14 +20,34 @@ module Mistri
16
20
  # t.timestamps
17
21
  # end
18
22
  # add_index :mistri_documents, [:session_id, :path], unique: true
23
+ #
24
+ # Reads run uncached: other processes write documents by design, and a
25
+ # cached read inside a job would serve the job's first snapshot forever.
26
+ # Uncached only defeats Rails' cache; an open REPEATABLE READ transaction
27
+ # still pins what reads see, so do not read from inside one.
19
28
  class ActiveRecord
29
+ MYSQL_ADAPTERS = %w[Mysql2 Trilogy].freeze
30
+ private_constant :MYSQL_ADAPTERS
31
+
20
32
  def initialize(model, scope: {})
33
+ raise ArgumentError, "scope must be a Hash" unless scope.is_a?(Hash)
34
+
35
+ names = scope.keys.map(&:to_s)
36
+ if names.uniq.length != names.length
37
+ raise ArgumentError, "scope keys must name distinct columns"
38
+ end
39
+
40
+ reserved = names & %w[path content]
41
+ unless reserved.empty?
42
+ raise ArgumentError, "scope cannot contain #{reserved.map(&:inspect).join(" or ")}"
43
+ end
44
+
21
45
  @model = model
22
- @scope = scope
46
+ @scope = own_scope(scope)
23
47
  end
24
48
 
25
49
  def read(path)
26
- @model.where(**@scope, path: path.to_s).pick(:content)
50
+ @model.uncached { @model.where(**@scope, path: path.to_s).pick(:content) }
27
51
  end
28
52
 
29
53
  def write(path, content)
@@ -33,15 +57,176 @@ module Mistri
33
57
  nil
34
58
  end
35
59
 
60
+ def atomic_writes?
61
+ return @atomic_writes if defined?(@atomic_writes)
62
+
63
+ @atomic_writes = atomic_storage?
64
+ end
65
+
66
+ def snapshot(path)
67
+ verify_atomic_context!
68
+ content = @model.uncached { relation(path).pick(:content) }
69
+ content.nil? ? nil : Snapshot.for(content.to_s)
70
+ end
71
+
72
+ def compare_and_write(path, content, expected_revision:)
73
+ verify_atomic_context!
74
+ key = path.to_s
75
+ return create_if_absent(key, content) if expected_revision.nil?
76
+
77
+ @model.transaction do
78
+ record = @model.lock.find_by(**@scope, path: key)
79
+ actual = record && Snapshot.for(record.content.to_s).revision
80
+ unless actual == expected_revision
81
+ raise WorkspaceConflictError.new(
82
+ key, expected_revision:, actual_revision: actual
83
+ )
84
+ end
85
+
86
+ record.content = content.to_s
87
+ record.save!
88
+ committed_snapshot(record, key)
89
+ end
90
+ end
91
+
36
92
  def delete(path)
37
93
  @model.where(**@scope, path: path.to_s).delete_all
38
94
  nil
39
95
  end
40
96
 
41
97
  def list(prefix = nil)
42
- paths = @model.where(**@scope).pluck(:path).sort
98
+ paths = @model.uncached { @model.where(**@scope).pluck(:path) }.sort
43
99
  prefix ? paths.select { |p| p.start_with?(prefix.to_s) } : paths
44
100
  end
101
+
102
+ private
103
+
104
+ def verify_atomic_context!
105
+ unless atomic_writes?
106
+ adapter = @model.connection.adapter_name
107
+ raise ConfigurationError,
108
+ "atomic Active Record workspace writes require PostgreSQL or InnoDB, " \
109
+ "a matching non-null primary key, non-null scope/path/content columns, " \
110
+ "and an exact unique scope/path key; #{@model.table_name.inspect} on " \
111
+ "#{adapter} does not qualify"
112
+ end
113
+ return unless @model.connection.transaction_open?
114
+
115
+ raise ConfigurationError,
116
+ "atomic Active Record workspace operations cannot join an open transaction"
117
+ end
118
+
119
+ def relation(path) = @model.where(**@scope, path: path.to_s)
120
+
121
+ def create_if_absent(path, content)
122
+ @model.transaction do
123
+ record = @model.create!(**@scope, path:, content: content.to_s)
124
+ committed_snapshot(record, path)
125
+ end
126
+ rescue ::ActiveRecord::RecordNotUnique
127
+ raise unless relation(path).exists?
128
+
129
+ actual = snapshot(path)&.revision
130
+ raise WorkspaceConflictError.new(path, actual_revision: actual)
131
+ end
132
+
133
+ def atomic_storage?
134
+ adapter = @model.connection.adapter_name
135
+ supported = adapter == "PostgreSQL" ||
136
+ (MYSQL_ADAPTERS.include?(adapter) && innodb_table?)
137
+ supported && atomic_schema?
138
+ end
139
+
140
+ def innodb_table?
141
+ connection = @model.connection
142
+ database, table = mysql_table_coordinates(connection)
143
+ sql = "SELECT ENGINE FROM information_schema.TABLES " \
144
+ "WHERE TABLE_SCHEMA = #{connection.quote(database)} " \
145
+ "AND TABLE_NAME = #{connection.quote(table)}"
146
+ connection.select_value(sql, "SCHEMA")&.casecmp?("InnoDB") == true
147
+ end
148
+
149
+ def mysql_table_coordinates(connection)
150
+ names = @model.table_name.to_s.split(".", 2)
151
+ return names if names.length == 2
152
+
153
+ [connection.current_database, names.first]
154
+ end
155
+
156
+ def atomic_schema?
157
+ connection = @model.connection
158
+ columns = connection.columns(@model.table_name).to_h { |column| [column.name, column] }
159
+ primary_columns = Array(@model.primary_key).compact.map(&:to_s)
160
+ catalog_primary = Array(connection.primary_keys(@model.table_name)).map(&:to_s)
161
+ return false unless primary_columns.sort == catalog_primary.sort
162
+ return false if primary_columns.empty? || !singular_scope?
163
+
164
+ required_columns = (@scope.keys.map(&:to_s) + %w[path content]).uniq
165
+ required_columns.concat(primary_columns).uniq!
166
+ return false unless non_null_columns?(columns, required_columns)
167
+
168
+ identity = (@scope.keys.map(&:to_s) + ["path"]).uniq.sort
169
+ return true if catalog_primary.sort == identity
170
+
171
+ exact_unique_identity?(connection, identity)
172
+ end
173
+
174
+ def exact_unique_identity?(connection, identity)
175
+ connection.indexes(@model.table_name).any? do |index|
176
+ valid = !index.respond_to?(:valid?) || index.valid?
177
+ columns = Array(index.columns).map(&:to_s).sort
178
+ index.unique && valid && !index.where && columns == identity
179
+ end
180
+ end
181
+
182
+ def non_null_columns?(columns, required)
183
+ required.all? do |name|
184
+ column = columns[name]
185
+ column && !column.null
186
+ end
187
+ end
188
+
189
+ def committed_snapshot(record, path)
190
+ identity = Array(@model.primary_key).compact.to_h do |column|
191
+ attributes = record.attributes
192
+ value = attributes.fetch(column.to_s) { attributes.fetch(column.to_sym) }
193
+ [column.to_sym, value]
194
+ end
195
+ if identity.empty?
196
+ raise ConfigurationError, "atomic Active Record workspace needs a primary key"
197
+ end
198
+
199
+ content = @model.where(**identity, **@scope, path:).pick(:content)
200
+ if content.nil?
201
+ raise SchemaError, "document #{path.inspect} changed identity during its write"
202
+ end
203
+
204
+ Snapshot.for(content.to_s)
205
+ end
206
+
207
+ def singular_scope?
208
+ @scope.values.all? do |value|
209
+ case value
210
+ when String, Symbol, Numeric, TrueClass, FalseClass, Time then true
211
+ else defined?(Date) && value.is_a?(Date)
212
+ end
213
+ end
214
+ end
215
+
216
+ def own_scope(value)
217
+ case value
218
+ when Hash
219
+ value.to_h { |key, item| [own_scope(key), own_scope(item)] }.freeze
220
+ when Array
221
+ value.map { |item| own_scope(item) }.freeze
222
+ when Range
223
+ Range.new(own_scope(value.begin), own_scope(value.end), value.exclude_end?).freeze
224
+ when String
225
+ String.new(value).freeze
226
+ else
227
+ value
228
+ end
229
+ end
45
230
  end
46
231
  end
47
232
  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 one root. Every path resolves inside the root
8
- # or raises, so a model-supplied "../../etc/passwd" cannot escape.
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
- @root = File.expand_path(root)
12
- FileUtils.mkdir_p(@root)
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
- base = @root.length + 1
35
- paths = Dir.glob(File.join(@root, "**", "*")).select { |f| File.file?(f) }
36
- .map { |f| f[base..] }.sort
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?("#{@root}#{File::SEPARATOR}")
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.to_s.dup.freeze }
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
- @path = path.to_s
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
- raise SchemaError, "this workspace holds only #{@path.inspect}" unless path.to_s == @path
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,10 +5,12 @@ 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"
@@ -19,6 +21,7 @@ require_relative "mistri/edit"
19
21
  require_relative "mistri/tool_context"
20
22
  require_relative "mistri/tool_result"
21
23
  require_relative "mistri/tool"
24
+ require_relative "mistri/workspace"
22
25
  require_relative "mistri/workspace/memory"
23
26
  require_relative "mistri/workspace/directory"
24
27
  require_relative "mistri/workspace/single"
@@ -55,6 +58,8 @@ require_relative "mistri/providers/gemini"
55
58
 
56
59
  # Mistri (مستری): the fixer. An agent harness for Ruby applications.
57
60
  module Mistri
61
+ private_constant :ToolArguments
62
+
58
63
  PROVIDERS = { anthropic: Providers::Anthropic, openai: Providers::OpenAI,
59
64
  gemini: Providers::Gemini }.freeze
60
65
  API_KEY_ENV = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY",
@@ -71,7 +76,7 @@ module Mistri
71
76
  # reading its key from the environment unless one is passed.
72
77
  #
73
78
  # Mistri.provider("claude-opus-4-8")
74
- # Mistri.provider("gpt-5.5", api_key: key, reasoning: { effort: "high" })
79
+ # Mistri.provider("gpt-5.6", api_key: key, reasoning: { effort: "high" })
75
80
  def provider(model, api_key: nil, **)
76
81
  name = provider_name(model)
77
82
  klass = PROVIDERS.fetch(name) { raise ConfigurationError, "no provider for #{model.inspect}" }