mistri 0.5.0 → 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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +469 -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 +1 -1
  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 +183 -3
  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
@@ -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,
@@ -3,29 +3,33 @@
3
3
  module Mistri
4
4
  # Host policy for spawning workers, as one object: the tool pool children
5
5
  # may draw from, the curated types, the model allowlist, the headcount
6
- # cap, and the dispatcher that makes background mode real. #tool builds
7
- # the spawn_agent tool the top-level agent holds; SubAgent.spawner and
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
8
10
  # SubAgent.pack are the front doors.
9
11
  #
10
12
  # Every policy violation answers the model in band (unknown type, missing
11
- # instructions, over capacity, workspace sharing in background mode);
12
- # only host configuration mistakes raise, and they raise at construction.
13
+ # instructions, over capacity); only host configuration mistakes raise,
14
+ # and they raise at construction.
13
15
  class Spawner
14
16
  def initialize(provider:, tools: [], types: {}, models: [], max_children: 4,
15
- dispatcher: nil, needs_approval: false, **agent_options)
16
- SubAgent.forbid_gated!(tools)
17
- if tools.any? { |tool| tool.name == "spawn_agent" }
18
- raise ConfigurationError, "the spawn tool never goes in its own pool"
19
- end
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)
20
23
 
21
24
  @provider = provider
22
- @pool = tools
25
+ @pool = tools.freeze
23
26
  # Symbol keys are natural Ruby; the wire speaks strings. One
24
27
  # normalization here and lookup, schema, and menu all agree.
25
- @types = types.transform_keys(&:to_s)
26
- @models = models
28
+ @types = types.transform_keys(&:to_s).freeze
29
+ @models = models.map { |model| String.new(model).freeze }.freeze
27
30
  @max_children = max_children
28
31
  @dispatcher = dispatcher
32
+ @runtime_factory = runtime_factory
29
33
  @needs_approval = needs_approval
30
34
  @agent_options = agent_options
31
35
  validate_types!
@@ -46,26 +50,62 @@ module Mistri
46
50
  worker = resolve_worker(args)
47
51
  return worker if worker.is_a?(String)
48
52
 
49
- if args["mode"] == "background" && @dispatcher
50
- if args["workspace"] == "parent"
51
- return "A worker sharing your workspace must run inline: a blocked parent " \
52
- "cannot write concurrently, a working one can. Drop workspace or " \
53
- "drop background."
54
- end
55
-
56
- return dispatch(args, worker, context)
57
- end
53
+ return dispatch(args, worker, context) if args["mode"] == "background" && @dispatcher
58
54
 
59
- SubAgent.run_child(label: label_for(args), provider: worker[:provider],
55
+ SubAgent.run_child(label: label_for(args), provider: provider_for(worker),
60
56
  system: worker[:system], tools: worker[:tools],
61
- task: args.fetch("task"), context: context, **@agent_options)
57
+ task: args.fetch("task"), parent_context: context,
58
+ agent_options: @agent_options)
62
59
  end
63
60
 
64
61
  private
65
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
+
66
106
  # Create the child session and its lifecycle entries, hand the
67
- # dispatcher the serializable spec plus an in-process runner closing
68
- # over the reconstructed pieces, and answer with a truthful receipt:
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:
69
109
  # what the child's status says after dispatch, not what the mode
70
110
  # promised. The runner closes over the spawn-time emit, so in-process
71
111
  # dispatchers keep streaming to whoever watched the spawn.
@@ -73,28 +113,37 @@ module Mistri
73
113
  store = context.session ? context.session.store : Stores::Memory.new
74
114
  child = Session.new(store: store)
75
115
  label = label_for(args)
76
- context.session&.append("subagent", "name" => label, "session_id" => child.id)
77
- child.append(Child::DISPATCHED, {})
78
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)
79
119
  emit = context.emit
80
- options = @agent_options
120
+ runtime_factory = @runtime_factory
81
121
  runner = lambda do
82
- SubAgent.run_dispatched(spec, provider: worker[:provider], system: worker[:system],
83
- tools: worker[:tools], store: store, emit: emit,
84
- **options)
122
+ SubAgent.run_dispatched(spec, runtime_factory: runtime_factory,
123
+ store: store, emit: emit,
124
+ retry_factory_errors: false)
85
125
  end
86
126
  @dispatcher.call(spec, runner)
87
127
  receipt(label, child, store)
88
128
  end
89
129
 
90
130
  def spec_for(args, worker, child, label, context)
91
- { "name" => label, "session_id" => child.id,
92
- "parent_session_id" => context.session&.id,
93
- "type" => args["type"] || "general-purpose",
94
- "instructions" => args["instructions"], "task" => args.fetch("task"),
95
- "tool_names" => worker[:tools].map(&:name),
96
- "model" => (worker[:provider].model if worker[:provider].respond_to?(:model)),
97
- "workspace" => args["workspace"] || "own" }
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"
98
147
  end
99
148
 
100
149
  def receipt(label, child, store)
@@ -127,9 +176,9 @@ module Mistri
127
176
 
128
177
  system = [definition.render, args["instructions"]]
129
178
  .reject { |part| part.to_s.strip.empty? }.join("\n\n")
130
- chosen = args["tools"].nil? || args["tools"].empty? ? definition.tool_names : args["tools"]
179
+ chosen = args["tools"].nil? ? definition.tool_names : args["tools"]
131
180
  { system: system, tools: pick(chosen),
132
- provider: typed_provider(args["model"], definition.model) }
181
+ **provider_choice(args["model"], definition.model) }
133
182
  end
134
183
 
135
184
  def general_worker(args)
@@ -139,29 +188,29 @@ module Mistri
139
188
  "or pick a type."
140
189
  end
141
190
 
142
- { system: system, tools: pick(args["tools"]),
143
- provider: child_provider(args["model"]) }
191
+ { system: system, tools: pick(args["tools"]), **provider_choice(args["model"]) }
144
192
  end
145
193
 
146
- # An explicit model choice goes through the allowlist; otherwise a
147
- # typed worker runs on its definition's model, and without one it
148
- # inherits the parent's provider. Host-curated definitions are
149
- # trusted the way the pool is.
150
- def typed_provider(requested, definition_model)
151
- return child_provider(requested) unless requested.to_s.empty?
152
- return @provider if definition_model.to_s.empty?
153
-
154
- Mistri.provider(definition_model)
155
- end
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?
156
200
 
157
- def child_provider(requested)
158
- return @provider if requested.nil? || requested.to_s.empty?
201
+ model = @provider.model if @provider.respond_to?(:model)
202
+ return { provider: @provider, model: model }
203
+ end
159
204
  unless @models.include?(requested)
160
205
  raise ArgumentError,
161
206
  "model #{requested.inspect} is not allowed; available: #{@models.join(", ")}"
162
207
  end
163
208
 
164
- Mistri.provider(requested)
209
+ { model: requested }
210
+ end
211
+
212
+ def provider_for(worker)
213
+ worker[:provider] || Mistri.provider(worker.fetch(:model))
165
214
  end
166
215
 
167
216
  def over_capacity(session)
@@ -199,7 +248,10 @@ module Mistri
199
248
  end
200
249
 
201
250
  def pick(names)
202
- return @pool if names.nil? || names.empty?
251
+ return @pool if names.nil?
252
+ if names.uniq.length != names.length
253
+ raise ArgumentError, "tool selections cannot contain duplicate names"
254
+ end
203
255
 
204
256
  by_name = @pool.to_h { |tool| [tool.name, tool] }
205
257
  names.map do |name|
@@ -229,17 +281,15 @@ module Mistri
229
281
  string :instructions, "The child's system prompt", required: true
230
282
  end
231
283
  if pool_names.any?
232
- array :tools, "Subset of tools to grant (default: all, or the type's own list)",
233
- items: { type: "string", enum: pool_names }
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
234
287
  end
235
288
  string :model, "Model for the child#{fallback}", enum: models if models.any?
236
289
  if dispatcher
237
290
  string :mode, "inline blocks until the report; background returns a receipt " \
238
291
  "now and you keep working (default: inline)",
239
292
  enum: %w[inline background]
240
- string :workspace, "own gives the worker its own file space; parent shares " \
241
- "yours and requires inline mode (default: own)",
242
- enum: %w[own parent]
243
293
  end
244
294
  end
245
295
  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,7 +15,7 @@ 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
@@ -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