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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +596 -3
  3. data/CONTRIBUTING.md +52 -0
  4. data/README.md +291 -306
  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/abort_signal.rb +10 -0
  28. data/lib/mistri/agent.rb +635 -108
  29. data/lib/mistri/budget.rb +26 -1
  30. data/lib/mistri/child.rb +186 -0
  31. data/lib/mistri/compaction.rb +26 -10
  32. data/lib/mistri/compactor.rb +35 -12
  33. data/lib/mistri/console.rb +209 -0
  34. data/lib/mistri/content.rb +9 -3
  35. data/lib/mistri/dispatchers.rb +49 -0
  36. data/lib/mistri/errors.rb +83 -4
  37. data/lib/mistri/event.rb +30 -8
  38. data/lib/mistri/event_delivery.rb +60 -0
  39. data/lib/mistri/locks/rails_cache.rb +48 -0
  40. data/lib/mistri/locks.rb +141 -0
  41. data/lib/mistri/mcp/client.rb +74 -19
  42. data/lib/mistri/mcp/egress.rb +216 -0
  43. data/lib/mistri/mcp/oauth.rb +476 -127
  44. data/lib/mistri/mcp/wires.rb +115 -23
  45. data/lib/mistri/mcp.rb +43 -9
  46. data/lib/mistri/message.rb +21 -11
  47. data/lib/mistri/models.rb +160 -22
  48. data/lib/mistri/providers/anthropic/assembler.rb +282 -44
  49. data/lib/mistri/providers/anthropic/serializer.rb +14 -9
  50. data/lib/mistri/providers/anthropic.rb +29 -6
  51. data/lib/mistri/providers/fake.rb +36 -6
  52. data/lib/mistri/providers/gemini/assembler.rb +148 -21
  53. data/lib/mistri/providers/gemini/serializer.rb +78 -9
  54. data/lib/mistri/providers/gemini.rb +31 -5
  55. data/lib/mistri/providers/openai/assembler.rb +337 -60
  56. data/lib/mistri/providers/openai/serializer.rb +13 -12
  57. data/lib/mistri/providers/openai.rb +29 -5
  58. data/lib/mistri/providers/schema_capabilities.rb +214 -0
  59. data/lib/mistri/result.rb +8 -3
  60. data/lib/mistri/retry_policy.rb +2 -2
  61. data/lib/mistri/schema.rb +893 -75
  62. data/lib/mistri/session.rb +649 -47
  63. data/lib/mistri/sinks/coalesced.rb +17 -10
  64. data/lib/mistri/skill.rb +1 -1
  65. data/lib/mistri/skills.rb +1 -1
  66. data/lib/mistri/spawner.rb +316 -0
  67. data/lib/mistri/sse.rb +57 -14
  68. data/lib/mistri/stores/active_record.rb +22 -7
  69. data/lib/mistri/stores/jsonl.rb +3 -1
  70. data/lib/mistri/stores/memory.rb +21 -2
  71. data/lib/mistri/sub_agent/execution.rb +81 -0
  72. data/lib/mistri/sub_agent/runtime.rb +297 -0
  73. data/lib/mistri/sub_agent.rb +238 -103
  74. data/lib/mistri/task_output.rb +58 -0
  75. data/lib/mistri/tool.rb +102 -13
  76. data/lib/mistri/tool_arguments.rb +377 -0
  77. data/lib/mistri/tool_call.rb +43 -9
  78. data/lib/mistri/tool_context.rb +7 -5
  79. data/lib/mistri/tool_executor.rb +117 -26
  80. data/lib/mistri/tool_result.rb +15 -10
  81. data/lib/mistri/tools/edit_file.rb +62 -8
  82. data/lib/mistri/tools.rb +41 -4
  83. data/lib/mistri/transport.rb +149 -44
  84. data/lib/mistri/usage.rb +65 -13
  85. data/lib/mistri/version.rb +1 -1
  86. data/lib/mistri/workspace/active_record.rb +183 -3
  87. data/lib/mistri/workspace/directory.rb +28 -8
  88. data/lib/mistri/workspace/memory.rb +34 -9
  89. data/lib/mistri/workspace/single.rb +62 -5
  90. data/lib/mistri/workspace.rb +39 -0
  91. data/lib/mistri.rb +17 -1
  92. data/mistri.gemspec +34 -0
  93. metadata +38 -3
@@ -4,8 +4,10 @@ module Mistri
4
4
  module Sinks
5
5
  # Wraps any sink and merges bursts of streaming deltas, so a transport
6
6
  # broadcasts at UI speed instead of token speed. Deltas buffer per
7
- # content block and flush merged when the interval elapses or any other
8
- # event arrives, so ordering is preserved and a turn always ends flushed.
7
+ # content block per origin and flush merged when the interval elapses or
8
+ # any other event arrives, so ordering is preserved and a turn always
9
+ # ends flushed. Safe to share across threads: a background worker's
10
+ # events serialize with the parent's instead of racing the buffer.
9
11
  #
10
12
  # sink = Mistri::Sinks::Coalesced.new(
11
13
  # Mistri::Sinks::ActionCable.new("agent_1"), interval: 0.1,
@@ -18,16 +20,19 @@ module Mistri
18
20
  @interval = interval
19
21
  @buffer = nil
20
22
  @flushed_at = now
23
+ @mutex = Mutex.new
21
24
  end
22
25
 
23
26
  def call(event)
24
- unless DELTAS.include?(event.type)
25
- flush
26
- return @sink.call(event)
27
+ @mutex.synchronize do
28
+ if DELTAS.include?(event.type)
29
+ merge(event)
30
+ flush if now - @flushed_at >= @interval
31
+ else
32
+ flush
33
+ @sink.call(event)
34
+ end
27
35
  end
28
-
29
- merge(event)
30
- flush if now - @flushed_at >= @interval
31
36
  end
32
37
 
33
38
  def to_proc = method(:call).to_proc
@@ -35,10 +40,12 @@ module Mistri
35
40
  private
36
41
 
37
42
  # Merged deltas keep the newest partial, so a consumer that renders
38
- # snapshots always renders the latest one.
43
+ # snapshots always renders the latest one. Origin is part of a block's
44
+ # identity: two agents' lanes never fold into one event.
39
45
  def merge(event)
40
46
  same = @buffer && @buffer.type == event.type &&
41
- @buffer.content_index == event.content_index
47
+ @buffer.content_index == event.content_index &&
48
+ @buffer.origin == event.origin
42
49
  flush if @buffer && !same
43
50
  @buffer = if same
44
51
  @buffer.with(delta: @buffer.delta.to_s + event.delta.to_s,
data/lib/mistri/skill.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  module Mistri
4
4
  # One expert playbook: a name the model selects by, a description that
5
5
  # earns the selection, and the full body it reads before acting. Build
6
- # them from anywhere Skills.load reads a directory, and a host with
6
+ # them from anywhere: Skills.load reads a directory, and a host with
7
7
  # skills in a database constructs these directly.
8
8
  Skill = Data.define(:name, :description, :body) do
9
9
  def initialize(name:, description: "", body: "")
data/lib/mistri/skills.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  module Mistri
4
4
  # Loads skills and wires them into an agent: their one-line descriptions
5
5
  # ride the system prompt, and the model pulls a full body on demand with
6
- # the read_skill tool so a large library costs almost nothing until a
6
+ # the read_skill tool, so a large library costs almost nothing until a
7
7
  # skill is actually used.
8
8
  module Skills
9
9
  module_function
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # Host policy for spawning workers, as one object: the tool pool children
5
+ # may draw from, the curated types, the model allowlist, the headcount
6
+ # cap, and the dispatcher that makes background mode real. A host runtime
7
+ # factory reconstructs live dependencies after the dispatch boundary;
8
+ # model text never chooses or proves resource isolation. #tool builds the
9
+ # spawn_agent tool the top-level agent holds; SubAgent.spawner and
10
+ # SubAgent.pack are the front doors.
11
+ #
12
+ # Every policy violation answers the model in band (unknown type, missing
13
+ # instructions, over capacity); only host configuration mistakes raise,
14
+ # and they raise at construction.
15
+ class Spawner
16
+ def initialize(provider:, tools: [], types: {}, models: [], max_children: 4,
17
+ dispatcher: nil, runtime_factory: nil, needs_approval: false,
18
+ **agent_options)
19
+ tools = Array.new(tools) if tools.is_a?(Array)
20
+ models = Array.new(models) if models.is_a?(Array)
21
+ ChildAgentOptions.validate!(agent_options)
22
+ validate_configuration!(tools, models, dispatcher, runtime_factory)
23
+
24
+ @provider = provider
25
+ @pool = tools.freeze
26
+ # Symbol keys are natural Ruby; the wire speaks strings. One
27
+ # normalization here and lookup, schema, and menu all agree.
28
+ @types = types.transform_keys(&:to_s).freeze
29
+ @models = models.map { |model| String.new(model).freeze }.freeze
30
+ @max_children = max_children
31
+ @dispatcher = dispatcher
32
+ @runtime_factory = runtime_factory
33
+ @needs_approval = needs_approval
34
+ @agent_options = agent_options
35
+ validate_types!
36
+ end
37
+
38
+ def tool
39
+ spawner = self
40
+ Tool.define("spawn_agent", description, needs_approval: @needs_approval,
41
+ schema: schema) do |args, context|
42
+ spawner.spawn(args, context)
43
+ end
44
+ end
45
+
46
+ def spawn(args, context)
47
+ crowded = over_capacity(context.session)
48
+ return crowded if crowded
49
+
50
+ worker = resolve_worker(args)
51
+ return worker if worker.is_a?(String)
52
+
53
+ return dispatch(args, worker, context) if args["mode"] == "background" && @dispatcher
54
+
55
+ SubAgent.run_child(label: label_for(args), provider: provider_for(worker),
56
+ system: worker[:system], tools: worker[:tools],
57
+ task: args.fetch("task"), parent_context: context,
58
+ agent_options: @agent_options)
59
+ end
60
+
61
+ private
62
+
63
+ def validate_configuration!(tools, models, dispatcher, runtime_factory)
64
+ validate_tool_pool!(tools)
65
+ validate_models!(models)
66
+ validate_dispatcher!(dispatcher, runtime_factory)
67
+ end
68
+
69
+ def validate_tool_pool!(tools)
70
+ unless tools.is_a?(Array) && tools.all?(Tool)
71
+ raise ConfigurationError, "the spawn tool pool must contain only Mistri::Tool instances"
72
+ end
73
+
74
+ SubAgent.forbid_gated!(tools)
75
+ if tools.any? { |tool| tool.name == "spawn_agent" }
76
+ raise ConfigurationError, "the spawn tool never goes in its own pool"
77
+ end
78
+ return if tools.map(&:name).uniq.length == tools.length
79
+
80
+ raise ConfigurationError, "the spawn tool pool has duplicate tool names"
81
+ end
82
+
83
+ def validate_models!(models)
84
+ unless models.is_a?(Array) &&
85
+ models.all? { |model| model.is_a?(String) && !model.empty? }
86
+ raise ConfigurationError, "the spawn model allowlist must contain non-empty strings"
87
+ end
88
+ return if models.uniq.length == models.length
89
+
90
+ raise ConfigurationError, "the spawn model allowlist has duplicate model names"
91
+ end
92
+
93
+ def validate_dispatcher!(dispatcher, runtime_factory)
94
+ if dispatcher && !dispatcher.respond_to?(:call)
95
+ raise ConfigurationError, "dispatcher must be callable"
96
+ end
97
+ if dispatcher && !runtime_factory.respond_to?(:call)
98
+ raise ConfigurationError,
99
+ "a dispatcher requires runtime_factory: to reconstruct background children"
100
+ end
101
+ return unless runtime_factory && !dispatcher
102
+
103
+ raise ConfigurationError, "runtime_factory: requires a dispatcher"
104
+ end
105
+
106
+ # Create the child session and its lifecycle entries, hand the
107
+ # dispatcher the serializable spec plus an in-process runner that calls
108
+ # the host factory inside the worker, and answer with a truthful receipt:
109
+ # what the child's status says after dispatch, not what the mode
110
+ # promised. The runner closes over the spawn-time emit, so in-process
111
+ # dispatchers keep streaming to whoever watched the spawn.
112
+ def dispatch(args, worker, context)
113
+ store = context.session ? context.session.store : Stores::Memory.new
114
+ child = Session.new(store: store)
115
+ label = label_for(args)
116
+ spec = spec_for(args, worker, child, label, context)
117
+ context.session&.append("subagent", "name" => label, "session_id" => child.id)
118
+ child.append(Child::DISPATCHED, "spec" => spec)
119
+ emit = context.emit
120
+ runtime_factory = @runtime_factory
121
+ runner = lambda do
122
+ SubAgent.run_dispatched(spec, runtime_factory: runtime_factory,
123
+ store: store, emit: emit,
124
+ retry_factory_errors: false)
125
+ end
126
+ @dispatcher.call(spec, runner)
127
+ receipt(label, child, store)
128
+ end
129
+
130
+ def spec_for(args, worker, child, label, context)
131
+ model = worker[:model]
132
+ unless model.is_a?(String) && !model.empty?
133
+ raise ConfigurationError,
134
+ "a background child provider must expose a non-empty model identity"
135
+ end
136
+ spec = { "spec_version" => SubAgent::DISPATCH_SPEC_VERSION,
137
+ "name" => label, "session_id" => child.id,
138
+ "parent_session_id" => context.session&.id,
139
+ "type" => args["type"] || "general-purpose",
140
+ "instructions" => args["instructions"], "task" => args.fetch("task"),
141
+ "tool_names" => worker[:tools].map(&:name),
142
+ "model" => model }
143
+ owned, error = ToolArguments.canonicalize(spec)
144
+ return owned unless error
145
+
146
+ raise ConfigurationError, "background child spec is not bounded JSON"
147
+ end
148
+
149
+ def receipt(label, child, store)
150
+ status = Child.new(name: label, session_id: child.id, store: store).status
151
+ state = if %i[running queued].include?(status)
152
+ "is working in the background"
153
+ else
154
+ "already finished (#{status})"
155
+ end
156
+ ToolResult.new(
157
+ content: "#{label} #{state} (agent id #{child.id[0, 8]}). Keep working: its " \
158
+ "report will arrive in your context when it finishes. Meanwhile " \
159
+ "read_agent checks on it (wait: true blocks for the report), " \
160
+ "steer_agent adjusts it, stop_agent stops it.",
161
+ ui: { "agent" => label, "session_id" => child.id, "mode" => "background" }
162
+ )
163
+ end
164
+
165
+ # A worker's system prompt, tools, and provider, resolved from its
166
+ # type; a String answers the model in band instead of raising.
167
+ def resolve_worker(args)
168
+ type = args["type"].to_s
169
+ return general_worker(args) if type.empty? || type == "general-purpose"
170
+
171
+ definition = @types[type]
172
+ unless definition
173
+ return "Unknown worker type #{type.inspect}; available: " \
174
+ "#{["general-purpose", *@types.keys].join(", ")}."
175
+ end
176
+
177
+ system = [definition.render, args["instructions"]]
178
+ .reject { |part| part.to_s.strip.empty? }.join("\n\n")
179
+ chosen = args["tools"].nil? ? definition.tool_names : args["tools"]
180
+ { system: system, tools: pick(chosen),
181
+ **provider_choice(args["model"], definition.model) }
182
+ end
183
+
184
+ def general_worker(args)
185
+ system = args["instructions"].to_s
186
+ if system.strip.empty?
187
+ return "A general-purpose worker needs instructions: write its system prompt, " \
188
+ "or pick a type."
189
+ end
190
+
191
+ { system: system, tools: pick(args["tools"]), **provider_choice(args["model"]) }
192
+ end
193
+
194
+ # A background grant needs only a stable model identity; constructing
195
+ # its live provider belongs to the worker factory. Inline execution turns
196
+ # the same choice into a provider immediately before the child starts.
197
+ def provider_choice(requested, definition_model = nil)
198
+ if requested.nil? || requested.to_s.empty?
199
+ return { model: definition_model } unless definition_model.to_s.empty?
200
+
201
+ model = @provider.model if @provider.respond_to?(:model)
202
+ return { provider: @provider, model: model }
203
+ end
204
+ unless @models.include?(requested)
205
+ raise ArgumentError,
206
+ "model #{requested.inspect} is not allowed; available: #{@models.join(", ")}"
207
+ end
208
+
209
+ { model: requested }
210
+ end
211
+
212
+ def provider_for(worker)
213
+ worker[:provider] || Mistri.provider(worker.fetch(:model))
214
+ end
215
+
216
+ def over_capacity(session)
217
+ return nil unless session
218
+
219
+ busy = session.children.count { |child| Child::LIVE.include?(child.status) }
220
+ return nil if busy < @max_children
221
+
222
+ "You already have #{busy} workers running; wait for one to finish or stop one."
223
+ end
224
+
225
+ # Types fail at construction, never mid-spawn: every definition must
226
+ # render without vars (spawn types are self-contained prompts) and
227
+ # declare only tools the pool actually carries.
228
+ def validate_types!
229
+ return if @types.empty?
230
+ if @types.key?("general-purpose")
231
+ raise ConfigurationError, "\"general-purpose\" is the built-in type; pick another name"
232
+ end
233
+
234
+ pool_names = @pool.map(&:name)
235
+ @types.each do |name, definition|
236
+ begin
237
+ definition.render
238
+ rescue ConfigurationError => e
239
+ raise ConfigurationError,
240
+ "type #{name.inspect} cannot be a spawn type: #{e.message}"
241
+ end
242
+ missing = definition.tool_names - pool_names
243
+ unless missing.empty?
244
+ raise ConfigurationError,
245
+ "type #{name.inspect} declares tools the pool lacks: #{missing.join(", ")}"
246
+ end
247
+ end
248
+ end
249
+
250
+ def pick(names)
251
+ return @pool if names.nil?
252
+ if names.uniq.length != names.length
253
+ raise ArgumentError, "tool selections cannot contain duplicate names"
254
+ end
255
+
256
+ by_name = @pool.to_h { |tool| [tool.name, tool] }
257
+ names.map do |name|
258
+ by_name.fetch(name) do
259
+ raise ArgumentError,
260
+ "unknown tool #{name.inspect}; available: #{by_name.keys.join(", ")}"
261
+ end
262
+ end
263
+ end
264
+
265
+ def schema
266
+ pool_names = @pool.map(&:name)
267
+ type_names = ["general-purpose", *@types.keys]
268
+ types = @types
269
+ models = @models
270
+ dispatcher = @dispatcher
271
+ default = @provider.model if @provider.respond_to?(:model)
272
+ fallback = default ? " (default: #{default})" : ""
273
+ lambda do
274
+ string :name, "A short name for this worker, shown wherever its events appear"
275
+ string :task, "The child's complete task", required: true
276
+ if types.any?
277
+ string :type, "The kind of worker (default: general-purpose)", enum: type_names
278
+ string :instructions, "The worker's system prompt (required for " \
279
+ "general-purpose; appended to a typed worker's own)"
280
+ else
281
+ string :instructions, "The child's system prompt", required: true
282
+ end
283
+ if pool_names.any?
284
+ array :tools, "Exact tools to grant (omit for all, or the type's own list; " \
285
+ "an empty list grants none)",
286
+ items: { type: "string", enum: pool_names }, uniqueItems: true
287
+ end
288
+ string :model, "Model for the child#{fallback}", enum: models if models.any?
289
+ if dispatcher
290
+ string :mode, "inline blocks until the report; background returns a receipt " \
291
+ "now and you keep working (default: inline)",
292
+ enum: %w[inline background]
293
+ end
294
+ end
295
+ end
296
+
297
+ def description
298
+ text = SubAgent::SPAWNER_DESCRIPTION
299
+ unless @types.empty?
300
+ text += " Typed workers come ready-made (#{@types.keys.join(", ")}): their " \
301
+ "instructions, tools, and model are set; add instructions only to " \
302
+ "focus them. general-purpose workers are yours to compose."
303
+ end
304
+ if @dispatcher
305
+ text += " Use background mode when the work is long and you can keep helping " \
306
+ "meanwhile: you get a receipt now, its report arrives on its own when " \
307
+ "the worker finishes, and the console tools manage it in between."
308
+ end
309
+ text
310
+ end
311
+
312
+ def label_for(args)
313
+ SubAgent.sanitize_label(args["name"], fallback: "spawn")
314
+ end
315
+ end
316
+ end
data/lib/mistri/sse.rb CHANGED
@@ -3,6 +3,9 @@
3
3
  require "json"
4
4
 
5
5
  module Mistri
6
+ DEFAULT_MAX_RECORD_BYTES = 8 * 1024 * 1024
7
+ private_constant :DEFAULT_MAX_RECORD_BYTES
8
+
6
9
  # An incremental Server-Sent Events decoder. Feed it raw socket fragments in
7
10
  # any chunking; it buffers partial lines across fragments and yields each
8
11
  # complete "data:" record as a parsed Hash.
@@ -11,40 +14,80 @@ module Mistri
11
14
  # send: one single-line JSON object per "data:" record. "event:", "id:",
12
15
  # comment, and blank lines are ignored (the event name is duplicated inside
13
16
  # the data payload), OpenAI's "[DONE]" sentinel is dropped, and a record that
14
- # fails to parse is skipped rather than killing a live stream.
17
+ # fails to parse is skipped rather than killing a live stream. Only the
18
+ # partial line is bounded; the complete stream is not.
15
19
  class SSE
16
- def initialize
20
+ def initialize(max_record_bytes: DEFAULT_MAX_RECORD_BYTES)
21
+ unless max_record_bytes.is_a?(Integer) && max_record_bytes.positive?
22
+ raise ConfigurationError, "max_record_bytes: must be a positive integer"
23
+ end
24
+
25
+ @max_record_bytes = max_record_bytes
17
26
  @buffer = +""
18
27
  end
19
28
 
20
29
  def feed(fragment, &)
21
- @buffer << fragment
22
- while (newline = @buffer.index("\n"))
23
- line = @buffer.slice!(0, newline + 1)
24
- decode(line.chomp, &)
30
+ scan = fragment.encoding == Encoding::BINARY ? fragment : fragment.b
31
+ offset = 0
32
+ while (newline = scan.index("\n", offset))
33
+ append(fragment, offset, newline - offset)
34
+ line = @buffer
35
+ @buffer = +""
36
+ line.chomp!
37
+ decode(line, &)
38
+ offset = newline + 1
25
39
  end
40
+ append(fragment, offset, scan.bytesize - offset)
26
41
  nil
27
42
  end
28
43
 
29
44
  # Flush a trailing record that arrived without a final newline.
30
45
  def finish(&)
31
- decode(@buffer.chomp, &) unless @buffer.empty?
32
- @buffer.clear
46
+ unless @buffer.empty?
47
+ line = @buffer
48
+ @buffer = +""
49
+ line.chomp!
50
+ decode(line, &)
51
+ end
33
52
  nil
34
53
  end
35
54
 
36
55
  private
37
56
 
57
+ def append(fragment, offset, length)
58
+ return if length.zero?
59
+
60
+ if @buffer.bytesize + length > @max_record_bytes
61
+ raise ResponseTooLargeError.new(kind: :sse_line, limit: @max_record_bytes)
62
+ end
63
+
64
+ @buffer << fragment.byteslice(offset, length)
65
+ end
66
+
38
67
  def decode(line)
39
- return unless line.start_with?("data:")
68
+ return unless line.delete_prefix!("data:")
69
+
70
+ line.strip!
71
+ return if line.empty? || line == "[DONE]"
40
72
 
41
- data = line.delete_prefix("data:").strip
42
- return if data.empty? || data == "[DONE]"
73
+ case ToolArguments.raw_json_resource_error(line)
74
+ when "too_many_nodes"
75
+ raise ResponseTooComplexError.new(kind: :sse_record_tokens,
76
+ limit: ToolArguments::MAX_LEXICAL_TOKENS)
77
+ when "too_deep"
78
+ raise ResponseTooComplexError.new(kind: :sse_record_depth,
79
+ limit: ToolArguments::MAX_DEPTH)
80
+ when "number_too_large"
81
+ raise ResponseTooComplexError.new(kind: :sse_numeric_token,
82
+ limit: ToolArguments::MAX_NUMBER_BYTES)
83
+ end
43
84
 
44
- decoded = JSON.parse(data)
85
+ decoded = begin
86
+ JSON.parse(line)
87
+ rescue JSON::ParserError
88
+ nil
89
+ end
45
90
  yield decoded if decoded.is_a?(Hash)
46
- rescue JSON::ParserError
47
- nil
48
91
  end
49
92
  end
50
93
  end
@@ -15,26 +15,41 @@ module Mistri
15
15
  # create_table :mistri_entries do |t|
16
16
  # t.string :session_id, null: false, index: true
17
17
  # t.integer :position, null: false
18
- # t.text :payload, size: :medium, null: false
18
+ # t.text :payload, size: :long, null: false
19
19
  # t.timestamps
20
20
  # end
21
21
  # add_index :mistri_entries, [:session_id, :position], unique: true
22
22
  #
23
- # The unique index is load-bearing: one session has one writer at a time
24
- # (the loop is serial), and if a host ever runs two agents on one session,
25
- # a colliding append raises instead of silently corrupting entry order.
23
+ # The unique index is load-bearing: a session's loop is serial, but other
24
+ # writers append alongside it by design (a steer from a web process, a
25
+ # background child's report from a job), so appends are optimistic. Two
26
+ # writers that pick the same position collide on the index and the loser
27
+ # retries at the next one; entry order stays intact without a lock.
26
28
  #
27
29
  # Reads select only (position, payload) and sort in Ruby: ORDER BY over
28
30
  # rows carrying multi-megabyte payloads exhausts MySQL's sort buffer.
29
31
  class ActiveRecord
32
+ # Concurrent writers converge in one or two retries; a session would
33
+ # need this many simultaneous appenders to exhaust them.
34
+ APPEND_ATTEMPTS = 5
35
+
30
36
  def initialize(model)
31
37
  @model = model
32
38
  end
33
39
 
34
40
  def append(id, entry)
35
- position = @model.where(session_id: id).maximum(:position).to_i + 1
36
- @model.create!(session_id: id, position: position, payload: JSON.generate(entry))
37
- nil
41
+ payload = JSON.generate(entry)
42
+ attempts = 0
43
+ begin
44
+ position = @model.where(session_id: id).maximum(:position).to_i + 1
45
+ @model.create!(session_id: id, position: position, payload: payload)
46
+ nil
47
+ rescue ::ActiveRecord::RecordNotUnique
48
+ attempts += 1
49
+ raise if attempts >= APPEND_ATTEMPTS
50
+
51
+ retry
52
+ end
38
53
  end
39
54
 
40
55
  def load(id)
@@ -13,8 +13,10 @@ module Mistri
13
13
  FileUtils.mkdir_p(dir)
14
14
  end
15
15
 
16
+ # One write call per line: concurrent appenders (a steer, a child's
17
+ # report) interleave whole lines, never fragments.
16
18
  def append(id, entry)
17
- File.open(path(id), "a") { |file| file.puts(JSON.generate(entry)) }
19
+ File.write(path(id), "#{JSON.generate(entry)}\n", mode: "a")
18
20
  nil
19
21
  end
20
22
 
@@ -10,12 +10,31 @@ module Mistri
10
10
  end
11
11
 
12
12
  def append(id, entry)
13
- @mutex.synchronize { @entries[id] << entry }
13
+ @mutex.synchronize { @entries[id] << json_copy(entry, freeze: true) }
14
14
  nil
15
15
  end
16
16
 
17
17
  def load(id)
18
- @mutex.synchronize { @entries[id].dup }
18
+ @mutex.synchronize do
19
+ @entries[id].map { |entry| json_copy(entry, freeze: false) }
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ # Memory must preserve the same value ownership as stores that decode
26
+ # every read; otherwise a transcript reader can rewrite durable history.
27
+ def json_copy(value, freeze:)
28
+ owned = case value
29
+ when Hash
30
+ value.to_h do |key, nested|
31
+ [json_copy(key, freeze:), json_copy(nested, freeze:)]
32
+ end
33
+ when Array then value.map { |nested| json_copy(nested, freeze:) }
34
+ when String then value.dup
35
+ else value
36
+ end
37
+ freeze ? owned.freeze : owned
19
38
  end
20
39
  end
21
40
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ class SubAgent
5
+ class << self
6
+ # The hardened execution path every child uses. Inline work acquires
7
+ # and cleans up its lease here; a dispatched owner supplies its lease
8
+ # and retains it through terminal persistence and parent reporting.
9
+ def execute_child(child:, label:, provider:, system:, tools:, task:, schema:,
10
+ signal:, emit:, lease: nil, started: false, agent_options: {})
11
+ ChildAgentOptions.validate!(agent_options)
12
+ child.append(Child::STARTED, {}) unless started
13
+ owns_lease = lease.nil?
14
+ primary_error = nil
15
+ begin
16
+ lease ||= Locks.hold(Child.lease_key(child.id),
17
+ stop_key: Child.stop_key(child.id), signal: signal)
18
+ abort_before_child_start!(child, signal, lease)
19
+ if signal.aborted?
20
+ Result.new(message: nil, status: :aborted)
21
+ else
22
+ run_child_agent(child:, label:, provider:, system:, tools:, task:, schema:,
23
+ signal:, emit:, agent_options:)
24
+ end
25
+ rescue StandardError => e
26
+ primary_error = e
27
+ reported = EventDelivery.original(e)
28
+ child.append(Child::TERMINAL, "status" => "failed",
29
+ "error" => "#{reported.class}: #{reported.message}")
30
+ raise
31
+ ensure
32
+ cleanup_error = release_inline_lease(child, lease) if owns_lease
33
+ raise cleanup_error if cleanup_error && primary_error.nil?
34
+ end
35
+ end
36
+
37
+ def release_inline_lease(child, lease)
38
+ errors = []
39
+ begin
40
+ lease&.release
41
+ rescue StandardError => e
42
+ errors << e
43
+ end
44
+ begin
45
+ Mistri.locks&.clear_flag(Child.stop_key(child.id))
46
+ rescue StandardError => e
47
+ errors << e
48
+ end
49
+ errors.first
50
+ end
51
+
52
+ def abort_before_child_start!(child, signal, lease)
53
+ return unless Mistri.locks
54
+ return if lease && !Mistri.locks.flag?(Child.stop_key(child.id))
55
+
56
+ signal.abort!("stopped by user")
57
+ end
58
+
59
+ def run_child_agent(child:, label:, provider:, system:, tools:, task:, schema:,
60
+ signal:, emit:, agent_options:)
61
+ agent = Agent.new(provider: provider, session: child, system: system,
62
+ tools: tools, **agent_options)
63
+ origin = "#{label}##{child.id[0, 8]}"
64
+ tagged = ->(event) { forward(event, origin, emit) }
65
+ return agent.task(task, schema: schema, signal: signal, &tagged) if schema
66
+
67
+ agent.run(task, signal: signal, &tagged)
68
+ end
69
+
70
+ def forward(event, origin, emit)
71
+ return unless emit
72
+
73
+ tagged = event.origin ? "#{origin}>#{event.origin}" : origin
74
+ emit.call(event.with(origin: tagged))
75
+ end
76
+
77
+ private :abort_before_child_start!, :execute_child, :forward,
78
+ :release_inline_lease, :run_child_agent
79
+ end
80
+ end
81
+ end