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
@@ -0,0 +1,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # Agent options a child may inherit without replacing lifecycle-owned
5
+ # provider, session, prompt, tools, task, signal, or event state.
6
+ module ChildAgentOptions
7
+ ALLOWED = %i[
8
+ budget max_concurrency transform_context compaction retries skills
9
+ before_tool after_tool context
10
+ ].freeze
11
+ private_constant :ALLOWED
12
+
13
+ module_function
14
+
15
+ def validate!(options)
16
+ unsupported = options.keys - ALLOWED
17
+ return if unsupported.empty?
18
+
19
+ raise ConfigurationError,
20
+ "unsupported sub-agent options: #{unsupported.sort.join(", ")}"
21
+ end
22
+ end
23
+ private_constant :ChildAgentOptions
24
+
25
+ class SubAgent
26
+ DISPATCH_SPEC_VERSION = 1
27
+ DISPATCH_SPEC_KEYS = %w[
28
+ spec_version name session_id parent_session_id type instructions task tool_names model
29
+ ].freeze
30
+ UNSET_RUNTIME_FIELD = Object.new.freeze
31
+ private_constant :DISPATCH_SPEC_KEYS, :UNSET_RUNTIME_FIELD
32
+
33
+ # The live dependencies a host constructs for one dispatched child.
34
+ # Mistri verifies the provider and tools against the durable spec; the
35
+ # host owns tenant scope, backend isolation, and the freshness of every
36
+ # object placed here.
37
+ class Runtime
38
+ attr_reader :provider, :system, :tools, :schema, :agent_options
39
+
40
+ def initialize(provider:, system: nil, tools: [], schema: nil, cleanup: nil,
41
+ **agent_options)
42
+ raise ArgumentError, "runtime tools must be an Array" unless tools.is_a?(Array)
43
+ unless cleanup.nil? || cleanup.respond_to?(:call)
44
+ raise ArgumentError, "runtime cleanup must be callable"
45
+ end
46
+
47
+ @provider = provider
48
+ @system = system
49
+ @tools = Array.new(tools).freeze
50
+ @schema = schema
51
+ @cleanup = cleanup
52
+ @agent_options = agent_options.dup.freeze
53
+ freeze
54
+ end
55
+
56
+ def close = @cleanup&.call
57
+ end
58
+
59
+ # The fail-closed boundary between a durable dispatch spec and live Ruby
60
+ # dependencies. Kept separate from SubAgent's execution lifecycle so each
61
+ # concern stays auditable.
62
+ class RuntimeContract
63
+ Resolved = Data.define(:provider, :system, :tools, :schema, :agent_options)
64
+ private_constant :Resolved
65
+
66
+ class << self
67
+ def own_spec(spec)
68
+ owned, error = ToolArguments.canonicalize(spec)
69
+ unless error.nil? && owned.is_a?(Hash)
70
+ raise ConfigurationError, "dispatched child spec must be a bounded JSON object"
71
+ end
72
+
73
+ owned
74
+ end
75
+
76
+ def validate_identity!(spec)
77
+ %w[name session_id].each do |field|
78
+ value = spec[field]
79
+ unless value.is_a?(String) && !value.empty?
80
+ raise ConfigurationError, "dispatched child spec needs a non-empty #{field}"
81
+ end
82
+ end
83
+ end
84
+
85
+ def bind_spec(entries, supplied)
86
+ dispatched = entries.find { |entry| entry["type"] == Child::DISPATCHED }
87
+ raise DispatchGrantError, "child session has no durable dispatch grant" unless dispatched
88
+
89
+ stored = dispatched["spec"]
90
+ return supplied if stored.nil? && supplied["spec_version"].nil?
91
+ unless stored
92
+ raise DispatchGrantError, "versioned child is missing its durable dispatch grant"
93
+ end
94
+
95
+ authoritative = own_spec(stored)
96
+ unless supplied == authoritative
97
+ raise DispatchGrantError, "queue payload does not match the durable dispatch grant"
98
+ end
99
+
100
+ authoritative
101
+ end
102
+
103
+ def validate_resolution!(factory:, direct:)
104
+ supplied = direct.values.any? { |value| !value.equal?(UNSET_RUNTIME_FIELD) }
105
+ if factory
106
+ raise ArgumentError, "choose runtime_factory or direct runtime fields" if supplied
107
+ unless factory.respond_to?(:call)
108
+ raise ConfigurationError, "runtime_factory must be callable"
109
+ end
110
+ elsif direct.fetch(:provider).equal?(UNSET_RUNTIME_FIELD)
111
+ raise ConfigurationError, "run_dispatched needs runtime_factory or provider"
112
+ end
113
+ end
114
+
115
+ def validate_spec!(spec)
116
+ version = spec["spec_version"]
117
+ validate_version!(version)
118
+ validate_v1_shape!(spec) if version == DISPATCH_SPEC_VERSION
119
+ validate_task!(spec["task"])
120
+ validate_model!(spec["model"], version: version)
121
+ validate_tool_names!(spec["tool_names"])
122
+ end
123
+
124
+ def resolve(spec, factory:, direct:, &factory_failed)
125
+ return resolve_factory(spec, factory, &factory_failed) if factory
126
+
127
+ resolve_direct(direct)
128
+ end
129
+
130
+ def validate_runtime!(runtime, spec)
131
+ provider = runtime.provider
132
+ system = runtime.system
133
+ tools = runtime.tools
134
+ schema = runtime.schema
135
+ options = runtime.agent_options
136
+ validate_provider!(provider, spec["model"])
137
+ validate_agent_options!(options)
138
+ ordered = validate_tools!(tools, spec.fetch("tool_names"))
139
+ Resolved.new(provider: provider, system: system, tools: ordered,
140
+ schema: schema, agent_options: options)
141
+ end
142
+
143
+ def cleanup(runtime)
144
+ runtime&.close
145
+ nil
146
+ rescue StandardError => e
147
+ e
148
+ end
149
+
150
+ private
151
+
152
+ def validate_version!(version)
153
+ return if version.nil? || version == DISPATCH_SPEC_VERSION
154
+
155
+ raise ConfigurationError,
156
+ "unsupported dispatched child spec version #{version.inspect}"
157
+ end
158
+
159
+ def validate_task!(task)
160
+ return if task.is_a?(String) && !task.empty?
161
+
162
+ raise ConfigurationError, "dispatched child spec needs a non-empty task"
163
+ end
164
+
165
+ def validate_v1_shape!(spec)
166
+ extra = spec.keys - DISPATCH_SPEC_KEYS
167
+ unless extra.empty?
168
+ raise ConfigurationError,
169
+ "dispatched child spec has unknown fields: #{extra.join(", ")}"
170
+ end
171
+ validate_optional_string!(spec["parent_session_id"], "parent_session_id")
172
+ validate_required_string!(spec["type"], "type")
173
+ validate_optional_string!(spec["instructions"], "instructions")
174
+ end
175
+
176
+ def validate_model!(model, version:)
177
+ return if version.nil? && model.nil?
178
+ return if model.is_a?(String) && !model.empty?
179
+
180
+ raise ConfigurationError, "dispatched child spec model must be a non-empty string"
181
+ end
182
+
183
+ def validate_required_string!(value, field)
184
+ return if value.is_a?(String) && !value.empty?
185
+
186
+ raise ConfigurationError,
187
+ "dispatched child spec #{field} must be a non-empty string"
188
+ end
189
+
190
+ def validate_optional_string!(value, field)
191
+ return if value.nil? || value.is_a?(String)
192
+
193
+ raise ConfigurationError,
194
+ "dispatched child spec #{field} must be a string or null"
195
+ end
196
+
197
+ def validate_tool_names!(names)
198
+ valid = names.is_a?(Array) &&
199
+ names.all? { |name| name.is_a?(String) && !name.empty? }
200
+ unless valid
201
+ raise ConfigurationError,
202
+ "dispatched child spec tool_names must be non-empty strings"
203
+ end
204
+ return if names.uniq.length == names.length
205
+
206
+ raise ConfigurationError, "dispatched child spec has duplicate tool names"
207
+ end
208
+
209
+ def resolve_factory(spec, factory)
210
+ runtime = begin
211
+ factory.call(spec)
212
+ rescue DispatchGrantError
213
+ yield if block_given?
214
+ raise ConfigurationError,
215
+ "runtime_factory must not raise Mistri::DispatchGrantError; " \
216
+ "that class is reserved for queue grant verification"
217
+ rescue StandardError
218
+ yield if block_given?
219
+ raise
220
+ end
221
+ return runtime if runtime.instance_of?(Runtime)
222
+
223
+ raise ConfigurationError,
224
+ "runtime_factory must return Mistri::SubAgent::Runtime"
225
+ end
226
+
227
+ def resolve_direct(direct)
228
+ provider = direct.fetch(:provider)
229
+ Runtime.new(provider: provider,
230
+ system: value_or_nil(direct.fetch(:system)),
231
+ tools: value_or(direct.fetch(:tools), []),
232
+ schema: value_or_nil(direct.fetch(:schema)),
233
+ **value_or(direct.fetch(:agent_options), {}))
234
+ end
235
+
236
+ def value_or(value, fallback)
237
+ value.equal?(UNSET_RUNTIME_FIELD) ? fallback : value
238
+ end
239
+
240
+ def value_or_nil(value) = value_or(value, nil)
241
+
242
+ def validate_provider!(provider, expected_model)
243
+ unless provider.respond_to?(:stream)
244
+ raise ConfigurationError, "background runtime provider must respond to stream"
245
+ end
246
+ return unless expected_model
247
+
248
+ actual = provider.model.to_s if provider.respond_to?(:model)
249
+ return if actual == expected_model
250
+
251
+ raise ConfigurationError,
252
+ "background runtime model #{actual.inspect} does not match " \
253
+ "the granted model #{expected_model.inspect}"
254
+ end
255
+
256
+ def validate_agent_options!(options)
257
+ ChildAgentOptions.validate!(options)
258
+ return unless options.key?(:skills)
259
+
260
+ raise ConfigurationError,
261
+ "background runtime skills would add tools outside the durable grant"
262
+ end
263
+
264
+ def validate_tools!(tools, expected)
265
+ unless tools.all?(Tool)
266
+ raise ConfigurationError, "background runtime tools must be Mistri::Tool instances"
267
+ end
268
+
269
+ names = tools.map(&:name)
270
+ if names.uniq.length != names.length
271
+ raise ConfigurationError, "background runtime has duplicate tool names"
272
+ end
273
+
274
+ check_exact_grant!(expected, names)
275
+ by_name = tools.to_h { |tool| [tool.name, tool] }
276
+ ordered = expected.map { |name| by_name.fetch(name) }
277
+ SubAgent.forbid_gated!(ordered)
278
+ ordered
279
+ end
280
+
281
+ def check_exact_grant!(expected, names)
282
+ missing = expected - names
283
+ extra = names - expected
284
+ return if missing.empty? && extra.empty?
285
+
286
+ details = []
287
+ details << "missing: #{missing.join(", ")}" if missing.any?
288
+ details << "extra: #{extra.join(", ")}" if extra.any?
289
+ raise ConfigurationError,
290
+ "background runtime tools do not match the durable grant " \
291
+ "(#{details.join("; ")})"
292
+ end
293
+ end
294
+ end
295
+ private_constant :RuntimeContract
296
+ end
297
+ end
@@ -23,9 +23,10 @@ module Mistri
23
23
  #
24
24
  # spawn = Mistri::SubAgent.spawner(provider:, tools: [fetch_page, search])
25
25
  #
26
- # Children never receive a spawn tool: delegation is one level deep by
27
- # construction. Parallel fan-out costs nothing: several spawn calls in
28
- # one turn run concurrently on the executor pool.
26
+ # The open spawner never grants itself, preventing accidental recursion.
27
+ # Hosts may deliberately nest fixed specialists. Several spawn calls in
28
+ # one turn are scheduled concurrently; provider instances decide whether
29
+ # their network requests overlap.
29
30
  #
30
31
  # Child events forward into the parent's stream tagged with origin
31
32
  # ("researcher#ab12cd34"; nesting of named specialists joins with ">").
@@ -35,6 +36,8 @@ module Mistri
35
36
  # runtime is denied and reported in band. Gate the delegation itself
36
37
  # instead (needs_approval: on the definition or spawner).
37
38
  class SubAgent
39
+ require_relative "sub_agent/runtime"
40
+
38
41
  SPAWNER_DESCRIPTION =
39
42
  "Delegate a self-contained task to a focused child agent with a clean " \
40
43
  "context. The child starts blank: give it complete instructions and " \
@@ -50,6 +53,8 @@ module Mistri
50
53
  def initialize(name:, description:, provider:, system: nil, tools: [], schema: nil,
51
54
  **agent_options)
52
55
  SubAgent.forbid_gated!(tools)
56
+ @gate = agent_options.delete(:needs_approval) || false
57
+ ChildAgentOptions.validate!(agent_options)
53
58
  @name = name.to_s
54
59
  @description = description
55
60
  @provider = provider
@@ -57,7 +62,6 @@ module Mistri
57
62
  @tools = tools
58
63
  @schema = schema
59
64
  @agent_options = agent_options
60
- @gate = agent_options.delete(:needs_approval) || false
61
65
  end
62
66
 
63
67
  # The delegate tool: each call runs a fresh child and answers with its
@@ -83,8 +87,8 @@ module Mistri
83
87
  def run_child(task, context, name: nil)
84
88
  SubAgent.run_child(label: SubAgent.sanitize_label(name, fallback: @name),
85
89
  provider: @provider, system: @system,
86
- tools: @tools, task: task, context: context, schema: @schema,
87
- **@agent_options)
90
+ tools: @tools, task: task, parent_context: context, schema: @schema,
91
+ agent_options: @agent_options)
88
92
  end
89
93
 
90
94
  class << self
@@ -110,79 +114,141 @@ module Mistri
110
114
  label.empty? ? fallback : label
111
115
  end
112
116
 
113
- def run_child(label:, provider:, system:, tools:, task:, context:, schema: nil,
114
- **agent_options)
115
- store = context.session ? context.session.store : Stores::Memory.new
117
+ def run_child(label:, provider:, system:, tools:, task:, parent_context:, schema: nil,
118
+ agent_options: {})
119
+ ChildAgentOptions.validate!(agent_options)
120
+ store = parent_context.session ? parent_context.session.store : Stores::Memory.new
116
121
  child = Session.new(store: store)
117
- context.session&.append("subagent", "name" => label, "session_id" => child.id)
122
+ parent_context.session&.append("subagent", "name" => label, "session_id" => child.id)
118
123
  # An inline child runs on a signal derived from the parent's: the
119
124
  # parent's abort cascades down through the handle, while stopping
120
125
  # the child alone leaves the parent running.
121
- signal, cascade = context.signal ? context.signal.derive : [AbortSignal.new, nil]
126
+ signal, cascade = if parent_context.signal
127
+ parent_context.signal.derive
128
+ else
129
+ [AbortSignal.new, nil]
130
+ end
122
131
  result = begin
123
132
  execute_child(child: child, label: label, provider: provider, system: system,
124
133
  tools: tools, task: task, schema: schema, signal: signal,
125
- emit: context.emit, **agent_options)
134
+ emit: parent_context.emit, agent_options: agent_options)
135
+ rescue StandardError => e
136
+ unless child.entries.any? { |entry| entry["type"] == Child::TERMINAL }
137
+ child.append(Child::TERMINAL,
138
+ "status" => "failed", "error" => "#{e.class}: #{e.message}")
139
+ end
140
+ raise
126
141
  ensure
127
- context.signal&.remove_callback(cascade) if cascade
142
+ parent_context.signal&.remove_callback(cascade) if cascade
128
143
  end
129
144
  outcome = answer(result, label, child)
130
145
  child.append(Child::TERMINAL, terminal(result))
131
146
  outcome
132
147
  end
133
148
 
134
- # The host job's way back in: reconstruct provider, system, and tools
135
- # from the spec through host registries, then hand them here. Reopens
136
- # the child session the spawn created, runs it exactly like an inline
137
- # child (started entry, lease, stop watching, terminals), streams
138
- # origin-tagged events to the emit the job supplies, and reports the
139
- # outcome back to the parent (see report_back). A background child
140
- # runs on its own signal: the parent's turn is long over, so only
141
- # stop_agent and the stop flag end it early.
149
+ # The host job's way back in: a runtime factory turns the durable spec
150
+ # into live dependencies inside the worker. The compatible direct
151
+ # provider/system/tools form remains for hosts that already reconstruct
152
+ # those values in their job. Either form is checked against the spec
153
+ # before execution. The child then runs exactly like an inline child,
154
+ # streams origin-tagged events, and reports back to its parent.
142
155
  #
143
- # The child's lease is the exactly-once fence, so it is taken before
144
- # anything else: refused means another process is running this child
145
- # right now (a queue redelivered a live job), so leave its owner alone.
146
- # Holding it, a terminal decides: present means the child was
147
- # cancelled while queued or the queue retried a finished job, so
148
- # there is nothing to run; absent means run, and that includes the
149
- # child a crashed process left mid-run, which is exactly what queue
150
- # retries are for. Either kind of no-op returns nil.
151
- def run_dispatched(spec, provider:, system:, tools:, store:, emit: nil, schema: nil,
152
- **agent_options)
156
+ # The child lease is duplicate suppression, not an exactly-once claim:
157
+ # a refused delivery leaves the current holder alone. A terminal means
158
+ # a queued cancellation or finished retry and returns nil. The runner
159
+ # retains an acquired lease through terminal persistence and reporting,
160
+ # suppressing ordinary redelivery while that lease remains live.
161
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- ordered dispatch transitions are the safety contract
162
+ def run_dispatched(spec, store:, emit: nil, runtime_factory: nil,
163
+ provider: UNSET_RUNTIME_FIELD, system: UNSET_RUNTIME_FIELD,
164
+ tools: UNSET_RUNTIME_FIELD, schema: UNSET_RUNTIME_FIELD,
165
+ retry_factory_errors: true, **agent_options)
166
+ runtime = nil
167
+ resolved = nil
168
+ authorized = false
169
+ terminalize = false
170
+ factory_failed = false
171
+ retryable_exit = false
172
+ primary_error = nil
173
+ delivery_owned = false
174
+ spec = RuntimeContract.own_spec(spec)
175
+ RuntimeContract.validate_identity!(spec)
153
176
  child = Session.new(store: store, id: spec.fetch("session_id"))
177
+ spec = RuntimeContract.bind_spec(child.entries, spec)
178
+ authorized = true
179
+ direct = { provider: provider, system: system, tools: tools, schema: schema,
180
+ agent_options: agent_options.empty? ? UNSET_RUNTIME_FIELD : agent_options }
181
+ RuntimeContract.validate_resolution!(factory: runtime_factory, direct: direct)
154
182
  signal = AbortSignal.new
183
+ terminalize = true
184
+ delivery_owned = true
155
185
  lease = Locks.hold(Child.lease_key(child.id),
156
186
  stop_key: Child.stop_key(child.id), signal: signal)
157
- return nil if Mistri.locks && lease.nil?
187
+ if Mistri.locks && lease.nil?
188
+ delivery_owned = false
189
+ return nil
190
+ end
158
191
 
159
192
  if Child.new(name: spec.fetch("name"), session_id: child.id, store: store).finished?
160
- lease&.release
161
193
  return nil
162
194
  end
163
195
 
164
- result = execute_child(child: child, label: spec.fetch("name"), provider: provider,
165
- system: system, tools: tools, task: spec.fetch("task"),
166
- schema: schema, signal: signal, emit: emit, lease: lease,
167
- **agent_options)
196
+ child.append(Child::STARTED, {})
197
+ RuntimeContract.validate_spec!(spec)
198
+ signal.abort!("stopped by user") if Mistri.locks&.flag?(Child.stop_key(child.id))
199
+ unless signal.aborted?
200
+ runtime = RuntimeContract.resolve(spec, factory: runtime_factory, direct: direct) do
201
+ factory_failed = true
202
+ end
203
+ resolved = RuntimeContract.validate_runtime!(runtime, spec)
204
+ signal.abort!("stopped by user") if Mistri.locks&.flag?(Child.stop_key(child.id))
205
+ end
206
+ result = if signal.aborted?
207
+ Result.new(message: nil, status: :aborted)
208
+ else
209
+ execute_child(child: child, label: spec.fetch("name"),
210
+ provider: resolved.provider, system: resolved.system,
211
+ tools: resolved.tools, task: spec.fetch("task"),
212
+ schema: resolved.schema, signal: signal, emit: emit,
213
+ lease: lease, started: true,
214
+ agent_options: resolved.agent_options)
215
+ end
168
216
  deny_pending(result, child)
169
217
  child.append(Child::TERMINAL, terminal(result))
170
218
  result
171
219
  rescue StandardError => e
220
+ primary_error = e
172
221
  # Completion is a contract even when the runner dies in the
173
222
  # preamble: without this, a raise before the started entry (a lock
174
223
  # backend down, say) would leave the child reading :queued forever,
175
224
  # with nothing to report and nothing for a retry to heal.
176
- if child && !Child.new(name: spec.fetch("name"), session_id: child.id,
177
- store: store).finished?
178
- child.append(Child::TERMINAL,
179
- "status" => "failed", "error" => "#{e.class}: #{e.message}")
225
+ stopped = factory_failed && child_stop_requested?(child, signal)
226
+ retryable = factory_failed && retry_factory_errors && !stopped
227
+ retryable_exit = retryable
228
+ if child && terminalize && !retryable &&
229
+ !Child.new(name: spec.fetch("name"), session_id: child.id, store: store).finished?
230
+ terminal = if stopped
231
+ { "status" => "stopped" }
232
+ else
233
+ { "status" => "failed", "error" => "#{e.class}: #{e.message}" }
234
+ end
235
+ child.append(Child::TERMINAL, terminal)
180
236
  end
181
- lease&.release
182
237
  raise
183
238
  ensure
184
- report_back(spec, store, emit) if child
239
+ cleanup_error = RuntimeContract.cleanup(runtime)
240
+ begin
241
+ report_back(spec, store, emit) if child && authorized && delivery_owned
242
+ ensure
243
+ begin
244
+ Mistri.locks&.clear_flag(Child.stop_key(child.id)) if child && lease && !retryable_exit
245
+ ensure
246
+ lease&.release
247
+ end
248
+ end
249
+ raise cleanup_error if cleanup_error && primary_error.nil?
185
250
  end
251
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
186
252
 
187
253
  # A child cannot wait for a human, whichever door it entered by: any
188
254
  # calls parked for approval are denied AND settled with the denial as
@@ -195,7 +261,7 @@ module Mistri
195
261
  child.deny(call.id, note: "sub-agents cannot pause for human approval")
196
262
  child.append_message(Message.tool(
197
263
  content: "Denied: sub-agents cannot pause for human approval.",
198
- tool_call_id: call.id, tool_name: call.name
264
+ tool_call_id: call.id, tool_name: call.name, tool_error: true
199
265
  ))
200
266
  end
201
267
  end
@@ -225,51 +291,18 @@ module Mistri
225
291
 
226
292
  private
227
293
 
228
- # The one hardened execution path every child goes through, whichever
229
- # door it entered by. From the started entry on, every exit writes a
230
- # terminal, setup failures included, or the child would read as
231
- # running forever. The lease says "alive right now" to other
232
- # processes and its thread watches the child's stop flag; a
233
- # dispatched run hands in the lease it already holds (its run-or-not
234
- # decision happened under the fence), an inline child acquires its
235
- # own here. Either way, this path releases it.
236
- def execute_child(child:, label:, provider:, system:, tools:, task:, schema:,
237
- signal:, emit:, lease: nil, **agent_options)
238
- child.append(Child::STARTED, {})
239
- lease ||= Locks.hold(Child.lease_key(child.id),
240
- stop_key: Child.stop_key(child.id), signal: signal)
241
- begin
242
- agent = Agent.new(provider: provider, session: child, system: system,
243
- tools: tools, **agent_options)
244
- origin = "#{label}##{child.id[0, 8]}"
245
- tagged = ->(event) { forward(event, origin, emit) }
246
- if schema
247
- agent.task(task, schema: schema, signal: signal, &tagged)
248
- else
249
- agent.run(task, signal: signal, &tagged)
250
- end
251
- rescue StandardError => e
252
- child.append(Child::TERMINAL, "status" => "failed", "error" => "#{e.class}: #{e.message}")
253
- raise
254
- ensure
255
- lease&.release
256
- Mistri.locks&.clear_flag(Child.stop_key(child.id))
257
- end
258
- end
259
-
260
- def forward(event, origin, emit)
261
- return unless emit
294
+ def child_stop_requested?(child, signal)
295
+ return false unless child
262
296
 
263
- tagged = event.origin ? "#{origin}>#{event.origin}" : origin
264
- emit.call(event.with(origin: tagged))
297
+ signal&.aborted? || Mistri.locks&.flag?(Child.stop_key(child.id))
265
298
  end
266
299
 
267
- # Every terminal outcome reports back, exactly once. The report joins
268
- # the parent's inbox (a typed entry that folds at its next turn
269
- # boundary, exactly like a steer) and a :subagent_report event
300
+ # A lease-backed runner reports before releasing the child lease. The
301
+ # report joins the parent's inbox (a typed entry that folds at its next
302
+ # turn boundary, exactly like a steer) and a :subagent_report event
270
303
  # closes the child's lane in whatever UI watched the spawn. A child
271
304
  # that never ran has nothing to say, and the parent session drops a
272
- # duplicate delivery, so a redelivered job cannot repeat one.
305
+ # sequential duplicate delivery. Without locks, the host must serialize.
273
306
  def report_back(spec, store, emit)
274
307
  facade = Child.new(name: spec.fetch("name"), session_id: spec.fetch("session_id"),
275
308
  store: store)
@@ -301,12 +334,14 @@ module Mistri
301
334
  when :awaiting_approval
302
335
  deny_pending(result, child)
303
336
  ToolResult.new(content: "The #{label} sub-agent stopped: it needed human " \
304
- "approval, which sub-agents cannot wait for.", ui: link)
337
+ "approval, which sub-agents cannot wait for.", ui: link,
338
+ error: true)
305
339
  when :aborted
306
- ToolResult.new(content: "[the #{label} sub-agent was stopped]", ui: link)
340
+ ToolResult.new(content: "[the #{label} sub-agent was stopped]", ui: link, error: true)
307
341
  else
308
342
  reason = result.error_message || result.status
309
- ToolResult.new(content: "The #{label} sub-agent failed: #{reason}", ui: link)
343
+ ToolResult.new(content: "The #{label} sub-agent failed: #{reason}", ui: link,
344
+ error: true)
310
345
  end
311
346
  end
312
347
 
@@ -320,3 +355,5 @@ module Mistri
320
355
  end
321
356
  end
322
357
  end
358
+
359
+ require_relative "sub_agent/execution"
@@ -9,26 +9,37 @@ module Mistri
9
9
  module TaskOutput
10
10
  # Distinguishable from a parsed nil: JSON "null" is a valid value.
11
11
  PARSE_FAILED = Object.new.freeze
12
+ OUTPUT_TOO_LARGE = Object.new.freeze
13
+ OUTPUT_TOO_COMPLEX = Object.new.freeze
12
14
 
13
15
  module_function
14
16
 
15
17
  def prompt(input, schema)
18
+ plan = plan_for(schema)
16
19
  "#{input}\n\nAnswer with ONLY a JSON value matching this schema:\n" \
17
- "#{JSON.generate(Schema.strict(schema))}"
20
+ "#{JSON.generate(plan.schema)}"
18
21
  end
19
22
 
20
23
  def parse(text)
21
- body = text.to_s.strip
24
+ body = text.to_s
25
+ return OUTPUT_TOO_LARGE if body.bytesize > ToolArguments::MAX_BYTES
26
+
27
+ body = body.strip
22
28
  body = body[/\A```(?:json)?\s*(.*?)```\z/m, 1] || body
23
- JSON.parse(body)
24
- rescue JSON::ParserError
25
- PARSE_FAILED
29
+ value, error = ToolArguments.parse_json(body)
30
+ return OUTPUT_TOO_LARGE if error == "too_large"
31
+ return OUTPUT_TOO_COMPLEX if %w[too_deep too_many_nodes number_too_large].include?(error)
32
+ return PARSE_FAILED if error
33
+
34
+ value
26
35
  end
27
36
 
28
37
  def errors(value, schema)
38
+ return ["the answer exceeds the output byte limit"] if value.equal?(OUTPUT_TOO_LARGE)
39
+ return ["the answer exceeds the output complexity limit"] if value.equal?(OUTPUT_TOO_COMPLEX)
29
40
  return ["the answer is not valid JSON"] if value.equal?(PARSE_FAILED)
30
41
 
31
- Schema.violations(value, schema)
42
+ plan_for(schema).violations(value)
32
43
  end
33
44
 
34
45
  def fix_prompt(errors)
@@ -36,5 +47,12 @@ module Mistri
36
47
  "Your answer did not satisfy the required output schema. Problems:\n" \
37
48
  "#{lines}\nReply with ONLY the corrected JSON."
38
49
  end
50
+
51
+ def plan_for(schema)
52
+ return schema if schema.respond_to?(:schema) && schema.respond_to?(:violations)
53
+
54
+ Schema.task_plan(schema)
55
+ end
56
+ private_class_method :plan_for
39
57
  end
40
58
  end