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
@@ -3,7 +3,7 @@
3
3
  module Mistri
4
4
  # Delegation with a clean context: a child agent runs on its own session
5
5
  # (the caller's store, linked in the caller's transcript), and only its
6
- # final answer returns to the parent exploration never fills the
6
+ # final answer returns to the parent; exploration never fills the
7
7
  # parent's window. Compaction rescues a full context after the fact;
8
8
  # spawning avoids filling it in the first place. A child session is its
9
9
  # own single-provider session, so delegating to a cheaper model is the
@@ -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 " \
@@ -45,11 +48,13 @@ module Mistri
45
48
  attr_reader :name, :description
46
49
 
47
50
  # schema: makes the specialist answer in validated JSON (task mode
48
- # underneath) fan-out children then return a uniform shape the parent
51
+ # underneath), so fan-out children return a uniform shape the parent
49
52
  # synthesizes instead of five styles of prose.
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,12 +62,13 @@ 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
64
68
  # final text, plus {agent, session_id} on the ui channel so a host can
65
- # link the child's transcript.
69
+ # link the child's transcript. The model may name each run, so two
70
+ # parallel researchers read as "Corgi" and "Beagle" in lanes and lists
71
+ # instead of "researcher" twice.
66
72
  def tool
67
73
  sub = self
68
74
  blurb = "#{@description} Runs as a focused sub-agent with a clean " \
@@ -71,56 +77,207 @@ module Mistri
71
77
  schema: lambda {
72
78
  string :task, "Complete instructions for the sub-agent",
73
79
  required: true
80
+ string :name, "A short name for this run, shown wherever " \
81
+ "its events appear (default: the tool's name)"
74
82
  }) do |args, context|
75
- sub.run_child(args.fetch("task"), context)
83
+ sub.run_child(args.fetch("task"), context, name: args["name"])
76
84
  end
77
85
  end
78
86
 
79
- def run_child(task, context)
80
- SubAgent.run_child(label: @name, provider: @provider, system: @system,
81
- tools: @tools, task: task, context: context, schema: @schema,
82
- **@agent_options)
87
+ def run_child(task, context, name: nil)
88
+ SubAgent.run_child(label: SubAgent.sanitize_label(name, fallback: @name),
89
+ provider: @provider, system: @system,
90
+ tools: @tools, task: task, parent_context: context, schema: @schema,
91
+ agent_options: @agent_options)
83
92
  end
84
93
 
85
94
  class << self
86
- # The open spawn tool over a pool of tools the host allows children to
87
- # use. The model may name the worker (a display label riding origins
88
- # and the transcript link) and grant a tool subset by name; models: is
89
- # the host's allowlist of child model ids — without one, no model
90
- # choice is offered at all, so a hallucinated id can never construct a
91
- # provider or land children on an expensive model.
92
- def spawner(provider:, tools: [], models: [], needs_approval: false, **agent_options)
93
- forbid_gated!(tools)
94
- if tools.any? { |tool| tool.name == "spawn_agent" }
95
- raise ConfigurationError, "the spawn tool never goes in its own pool"
96
- end
95
+ # The open spawn tool: the model names each worker, grants it a tool
96
+ # subset from the host's pool, and may pick a type, a model, and a
97
+ # mode. All policy lives on Spawner; this is the front door.
98
+ def spawner(provider:, **)
99
+ Spawner.new(provider: provider, **).tool
100
+ end
97
101
 
98
- schema = spawner_schema(tools, models, default_model(provider))
99
- Tool.define("spawn_agent", SPAWNER_DESCRIPTION,
100
- needs_approval: needs_approval, schema: schema) do |args, context|
101
- run_child(label: child_label(args["name"]),
102
- provider: child_provider(provider, args["model"], models),
103
- system: args.fetch("instructions"),
104
- tools: pick(tools, args["tools"]),
105
- task: args.fetch("task"), context: context, **agent_options)
106
- end
102
+ # The whole kit in one call: the spawn tool plus the management
103
+ # console, so a host hands its agent everything workers need.
104
+ def pack(provider:, console: {}, **spawner_options)
105
+ [spawner(provider: provider, **spawner_options), *Console.tools(**console)]
106
+ end
107
+
108
+ # A worker's display name, made safe for origins: the label rides
109
+ # them as "label#id" and nesting joins with ">", so those separators
110
+ # squeeze to hyphens along with whitespace. Blank falls back.
111
+ def sanitize_label(text, fallback:)
112
+ label = text.to_s.gsub(/[#>\s]+/, "-").squeeze("-")[0, 32]
113
+ label = label.delete_prefix("-").delete_suffix("-")
114
+ label.empty? ? fallback : label
107
115
  end
108
116
 
109
- def run_child(label:, provider:, system:, tools:, task:, context:, schema: nil,
110
- **agent_options)
111
- 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
112
121
  child = Session.new(store: store)
113
- context.session&.append("subagent", "name" => label, "session_id" => child.id)
114
- agent = Agent.new(provider: provider, session: child, system: system,
115
- tools: tools, **agent_options)
116
- origin = "#{label}##{child.id[0, 8]}"
117
- emit = ->(event) { forward(event, origin, context) }
118
- result = if schema
119
- agent.task(task, schema: schema, signal: context.signal, &emit)
122
+ parent_context.session&.append("subagent", "name" => label, "session_id" => child.id)
123
+ # An inline child runs on a signal derived from the parent's: the
124
+ # parent's abort cascades down through the handle, while stopping
125
+ # the child alone leaves the parent running.
126
+ signal, cascade = if parent_context.signal
127
+ parent_context.signal.derive
128
+ else
129
+ [AbortSignal.new, nil]
130
+ end
131
+ result = begin
132
+ execute_child(child: child, label: label, provider: provider, system: system,
133
+ tools: tools, task: task, schema: schema, signal: signal,
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
141
+ ensure
142
+ parent_context.signal&.remove_callback(cascade) if cascade
143
+ end
144
+ outcome = answer(result, label, child)
145
+ child.append(Child::TERMINAL, terminal(result))
146
+ outcome
147
+ end
148
+
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.
155
+ #
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)
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)
182
+ signal = AbortSignal.new
183
+ terminalize = true
184
+ delivery_owned = true
185
+ lease = Locks.hold(Child.lease_key(child.id),
186
+ stop_key: Child.stop_key(child.id), signal: signal)
187
+ if Mistri.locks && lease.nil?
188
+ delivery_owned = false
189
+ return nil
190
+ end
191
+
192
+ if Child.new(name: spec.fetch("name"), session_id: child.id, store: store).finished?
193
+ return nil
194
+ end
195
+
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)
120
208
  else
121
- agent.run(task, signal: context.signal, &emit)
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)
122
215
  end
123
- answer(result, label, child)
216
+ deny_pending(result, child)
217
+ child.append(Child::TERMINAL, terminal(result))
218
+ result
219
+ rescue StandardError => e
220
+ primary_error = e
221
+ # Completion is a contract even when the runner dies in the
222
+ # preamble: without this, a raise before the started entry (a lock
223
+ # backend down, say) would leave the child reading :queued forever,
224
+ # with nothing to report and nothing for a retry to heal.
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)
236
+ end
237
+ raise
238
+ ensure
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?
250
+ end
251
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
252
+
253
+ # A child cannot wait for a human, whichever door it entered by: any
254
+ # calls parked for approval are denied AND settled with the denial as
255
+ # their tool result, so no approval request stays open on a finished
256
+ # child and its transcript replays without repair.
257
+ def deny_pending(result, child)
258
+ return unless result.awaiting_approval?
259
+
260
+ result.pending.each do |call|
261
+ child.deny(call.id, note: "sub-agents cannot pause for human approval")
262
+ child.append_message(Message.tool(
263
+ content: "Denied: sub-agents cannot pause for human approval.",
264
+ tool_call_id: call.id, tool_name: call.name, tool_error: true
265
+ ))
266
+ end
267
+ end
268
+
269
+ # Every child ends by writing its own terminal entry: completion is a
270
+ # contract, and status stays readable from the store forever.
271
+ def terminal(result)
272
+ case result.status
273
+ when :completed then { "status" => "done", "report" => result.text.to_s }
274
+ when :aborted then { "status" => "stopped" }
275
+ when :awaiting_approval
276
+ { "status" => "failed",
277
+ "error" => "needed human approval, which sub-agents cannot wait for" }
278
+ else
279
+ { "status" => "failed", "error" => (result.error_message || result.status).to_s }
280
+ end
124
281
  end
125
282
 
126
283
  def forbid_gated!(tools)
@@ -134,11 +291,36 @@ module Mistri
134
291
 
135
292
  private
136
293
 
137
- def forward(event, origin, context)
138
- return unless context.emit
294
+ def child_stop_requested?(child, signal)
295
+ return false unless child
296
+
297
+ signal&.aborted? || Mistri.locks&.flag?(Child.stop_key(child.id))
298
+ end
299
+
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
303
+ # closes the child's lane in whatever UI watched the spawn. A child
304
+ # that never ran has nothing to say, and the parent session drops a
305
+ # sequential duplicate delivery. Without locks, the host must serialize.
306
+ def report_back(spec, store, emit)
307
+ facade = Child.new(name: spec.fetch("name"), session_id: spec.fetch("session_id"),
308
+ store: store)
309
+ return unless facade.finished?
139
310
 
140
- tagged = event.origin ? "#{origin}>#{event.origin}" : origin
141
- context.emit.call(event.with(origin: tagged))
311
+ status = facade.status
312
+ text = status == :failed ? facade.error : facade.report
313
+ delivered = if (parent_id = spec["parent_session_id"])
314
+ Session.new(store: store, id: parent_id)
315
+ .deliver_report(name: facade.name, session_id: facade.session_id,
316
+ status: status.to_s, text: text)
317
+ else
318
+ true
319
+ end
320
+ return unless delivered
321
+
322
+ emit&.call(Event.new(type: :subagent_report, agent: facade.name,
323
+ session_id: facade.session_id, status: status, content: text))
142
324
  end
143
325
 
144
326
  # The parent always gets an in-band answer it can react to. A child
@@ -150,68 +332,19 @@ module Mistri
150
332
  when :completed
151
333
  ToolResult.new(content: result.text.to_s, ui: link)
152
334
  when :awaiting_approval
153
- result.pending.each do |call|
154
- child.deny(call.id, note: "sub-agents cannot pause for human approval")
155
- end
335
+ deny_pending(result, child)
156
336
  ToolResult.new(content: "The #{label} sub-agent stopped: it needed human " \
157
- "approval, which sub-agents cannot wait for.", ui: link)
337
+ "approval, which sub-agents cannot wait for.", ui: link,
338
+ error: true)
158
339
  when :aborted
159
- ToolResult.new(content: "[the #{label} sub-agent was aborted]", ui: link)
340
+ ToolResult.new(content: "[the #{label} sub-agent was stopped]", ui: link, error: true)
160
341
  else
161
342
  reason = result.error_message || result.status
162
- ToolResult.new(content: "The #{label} sub-agent failed: #{reason}", ui: link)
163
- end
164
- end
165
-
166
- def pick(pool, names)
167
- return pool if names.nil? || names.empty?
168
-
169
- by_name = pool.to_h { |tool| [tool.name, tool] }
170
- names.map do |name|
171
- by_name.fetch(name) do
172
- raise ArgumentError,
173
- "unknown tool #{name.inspect}; available: #{by_name.keys.join(", ")}"
174
- end
343
+ ToolResult.new(content: "The #{label} sub-agent failed: #{reason}", ui: link,
344
+ error: true)
175
345
  end
176
346
  end
177
347
 
178
- def spawner_schema(pool, models, default)
179
- tool_names = pool.map(&:name)
180
- fallback = default ? " (default: #{default})" : ""
181
- lambda do
182
- string :name, "A short name for this worker, shown wherever its events appear"
183
- string :task, "The child's complete task", required: true
184
- string :instructions, "The child's system prompt", required: true
185
- if tool_names.any?
186
- array :tools, "Subset of tools to grant (default: all)",
187
- items: { type: "string", enum: tool_names }
188
- end
189
- string :model, "Model for the child#{fallback}", enum: models if models.any?
190
- end
191
- end
192
-
193
- def default_model(provider)
194
- provider.model if provider.respond_to?(:model)
195
- end
196
-
197
- # The label rides origins as "label#id" and joins nesting with ">",
198
- # so those separators squeeze to hyphens along with whitespace.
199
- def child_label(raw)
200
- label = raw.to_s.gsub(/[#>\s]+/, "-").squeeze("-")[0, 32]
201
- label = label.delete_prefix("-").delete_suffix("-")
202
- label.empty? ? "spawn" : label
203
- end
204
-
205
- def child_provider(default, requested, models)
206
- return default if requested.nil? || requested.to_s.empty?
207
- unless models.include?(requested)
208
- raise ArgumentError,
209
- "model #{requested.inspect} is not allowed; available: #{models.join(", ")}"
210
- end
211
-
212
- Mistri.provider(requested)
213
- end
214
-
215
348
  # A predicate gate cannot be judged statically; the runtime denial in
216
349
  # answer covers it.
217
350
  def statically_gated?(tool)
@@ -222,3 +355,5 @@ module Mistri
222
355
  end
223
356
  end
224
357
  end
358
+
359
+ require_relative "sub_agent/execution"
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mistri
6
+ # The text side of task mode: how a schema is asked for, how the answer
7
+ # parses, and how a violation is sent back for a fix. Pure functions over
8
+ # strings and schemas; Agent#task owns the loop that drives them.
9
+ module TaskOutput
10
+ # Distinguishable from a parsed nil: JSON "null" is a valid value.
11
+ PARSE_FAILED = Object.new.freeze
12
+ OUTPUT_TOO_LARGE = Object.new.freeze
13
+ OUTPUT_TOO_COMPLEX = Object.new.freeze
14
+
15
+ module_function
16
+
17
+ def prompt(input, schema)
18
+ plan = plan_for(schema)
19
+ "#{input}\n\nAnswer with ONLY a JSON value matching this schema:\n" \
20
+ "#{JSON.generate(plan.schema)}"
21
+ end
22
+
23
+ def parse(text)
24
+ body = text.to_s
25
+ return OUTPUT_TOO_LARGE if body.bytesize > ToolArguments::MAX_BYTES
26
+
27
+ body = body.strip
28
+ body = body[/\A```(?:json)?\s*(.*?)```\z/m, 1] || body
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
35
+ end
36
+
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)
40
+ return ["the answer is not valid JSON"] if value.equal?(PARSE_FAILED)
41
+
42
+ plan_for(schema).violations(value)
43
+ end
44
+
45
+ def fix_prompt(errors)
46
+ lines = errors.map { |error| "- #{error}" }.join("\n")
47
+ "Your answer did not satisfy the required output schema. Problems:\n" \
48
+ "#{lines}\nReply with ONLY the corrected JSON."
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
57
+ end
58
+ end
data/lib/mistri/tool.rb CHANGED
@@ -4,49 +4,81 @@ require "json"
4
4
 
5
5
  module Mistri
6
6
  # A tool the agent can call: a name, a description, a JSON Schema for its
7
- # arguments, and a handler. The handler receives the parsed arguments hash
8
- # (string keys, exactly as the model sent them) and returns a String, a Hash
9
- # (serialized as JSON), or content blocks, so a tool can hand back images as
10
- # naturally as text.
7
+ # arguments, and a handler. Model calls reach the handler as canonical
8
+ # argument hashes, optionally normalized by the tool before policy sees
9
+ # them. Trusted direct calls keep ordinary Ruby Hash semantics.
11
10
  class Tool
12
11
  # A no-argument tool still needs a valid object schema; providers reject a
13
12
  # bare empty hash.
14
- EMPTY_SCHEMA = { type: "object", properties: {} }.freeze
13
+ EMPTY_SCHEMA = {
14
+ type: "object", properties: {}.freeze, required: [].freeze,
15
+ additionalProperties: false
16
+ }.freeze
15
17
 
16
18
  attr_reader :name, :description, :input_schema, :timeout
17
19
 
18
- # Define a tool. Give the argument shape as a raw JSON Schema hash via
19
- # input_schema:, or build it in Ruby with a schema: block.
20
+ # Define a tool. Give the argument shape as a JSON Schema value via
21
+ # input_schema:, or build it in Ruby with a schema: block. The result is
22
+ # canonicalized and owned once so provider and local semantics cannot drift.
20
23
  #
21
24
  # Tool.define("get_weather", "Weather for a city",
22
25
  # schema: -> { string :city, "City name", required: true }) do |args|
23
26
  # Weather.for(args["city"])
24
27
  # end
25
28
  def self.define(name, description, input_schema: nil, schema: nil, **, &handler)
26
- input_schema ||= schema ? Schema.build(&schema) : EMPTY_SCHEMA
29
+ raise ArgumentError, "choose input_schema or schema, not both" if schema && !input_schema.nil?
30
+
31
+ input_schema = schema ? Schema.build(&schema) : EMPTY_SCHEMA if input_schema.nil?
27
32
  new(name: name, description: description, input_schema: input_schema, **, &handler)
28
33
  end
29
34
 
30
35
  def initialize(name:, description:, input_schema: EMPTY_SCHEMA, eager_input_streaming: false,
31
- needs_approval: false, timeout: nil, &handler)
36
+ needs_approval: false, ends_turn: false, timeout: nil,
37
+ argument_normalizer: nil, argument_validator: nil,
38
+ complete_argument_validator: nil, &handler)
32
39
  raise ArgumentError, "tool #{name.inspect} needs a handler block" unless handler
40
+ unless argument_normalizer.nil? || argument_normalizer.respond_to?(:call)
41
+ raise ArgumentError, "argument_normalizer must be callable"
42
+ end
43
+ unless argument_validator.nil? || argument_validator.respond_to?(:call)
44
+ raise ArgumentError, "argument_validator must be callable"
45
+ end
46
+ unless complete_argument_validator.nil? || complete_argument_validator.respond_to?(:call)
47
+ raise ArgumentError, "complete_argument_validator must be callable"
48
+ end
49
+ if argument_validator && complete_argument_validator
50
+ raise ArgumentError,
51
+ "choose argument_validator or complete_argument_validator, not both"
52
+ end
33
53
 
34
54
  @name = name.to_s
35
55
  @description = description
36
- @input_schema = input_schema
56
+ @schema_validator = Schema.tool_validator(
57
+ input_schema, complete: !complete_argument_validator.nil?
58
+ )
59
+ @input_schema = @schema_validator.schema
37
60
  @eager_input_streaming = eager_input_streaming
38
61
  @needs_approval = needs_approval
62
+ @ends_turn = ends_turn
39
63
  @timeout = timeout
64
+ @argument_normalizer = argument_normalizer
65
+ @argument_validator = argument_validator
66
+ @complete_argument_validator = complete_argument_validator
40
67
  @handler = handler
41
68
  end
42
69
 
43
- # A handler may return a ToolResult to speak on two channels; its ui
44
- # payload is canonicalized through JSON here so the live event and a
45
- # reloaded session read the identical shape.
70
+ # A handler may return a ToolResult to add host-only UI or declare a
71
+ # model-readable failure. Its ui payload is canonicalized through JSON
72
+ # here so the live event and a reloaded session read the identical shape.
46
73
  #
47
74
  # Handlers receive (arguments, context). A proc that declares one
48
75
  # parameter ignores the context invisibly; a lambda opts in by arity.
76
+ # Direct calls are trusted host invocations: they apply the compatibility
77
+ # normalizer, but do not canonicalize or validate as model calls do. The
78
+ # executor marks arguments the Agent already prepared so subclasses keep
79
+ # the historical #call extension point without running normalization twice.
49
80
  def call(arguments, context = ToolContext.new)
81
+ arguments = normalize_arguments(arguments || {}) unless prepared_context?(context)
50
82
  result = invoke(arguments || {}, context)
51
83
  return serialize_result(result) unless result.is_a?(ToolResult)
52
84
 
@@ -54,6 +86,31 @@ module Mistri
54
86
  ui: result.ui && JSON.parse(JSON.generate(result.ui)))
55
87
  end
56
88
 
89
+ # Normalization is an explicit per-tool migration boundary, never a
90
+ # global coercion policy. Agent calls this once before policy; direct
91
+ # trusted invocations get the same compatibility behavior through #call.
92
+ def normalize_arguments(arguments)
93
+ return arguments unless @argument_normalizer
94
+
95
+ normalized = @argument_normalizer.call(arguments)
96
+ raise ArgumentError, "argument_normalizer must return a Hash" unless normalized.is_a?(Hash)
97
+
98
+ normalized
99
+ end
100
+
101
+ # Core validation is always authoritative for its portable subset. A
102
+ # supplemental validator adds domain rules; an explicitly complete one
103
+ # additionally owns schema interactions core cannot represent.
104
+ def argument_violations(arguments)
105
+ validate_arguments(arguments, owned: false)
106
+ end
107
+
108
+ # Agent has already moved the value through ToolCall's ownership boundary,
109
+ # so its hot path can avoid copying the same immutable JSON twice.
110
+ def prepared_argument_violations(arguments)
111
+ validate_arguments(arguments, owned: true)
112
+ end
113
+
57
114
  # Whether this call should pause for a human. true/false, or a callable
58
115
  # given the parsed arguments so a tool can gate only the risky calls
59
116
  # (needs_approval: ->(args) { args["amount"].to_i > 100 }).
@@ -61,6 +118,14 @@ module Mistri
61
118
  @needs_approval.respond_to?(:call) ? @needs_approval.call(arguments) : @needs_approval
62
119
  end
63
120
 
121
+ # A tool that is the last word of its turn: once it executes, the loop
122
+ # ends the run instead of prompting the model again. This is how a tool
123
+ # like ask_user hands the floor to a human structurally, with no prompt
124
+ # discipline required; the answer arrives as the next run's input.
125
+ def ends_turn?
126
+ @ends_turn
127
+ end
128
+
64
129
  # The provider-facing definition; every serializer accepts this shape.
65
130
  def spec
66
131
  definition = { name: @name, description: @description, input_schema: @input_schema }
@@ -70,6 +135,30 @@ module Mistri
70
135
 
71
136
  private
72
137
 
138
+ def prepared_context?(context)
139
+ context.respond_to?(:arguments_prepared?) && context.arguments_prepared?
140
+ end
141
+
142
+ def validate_arguments(arguments, owned:)
143
+ unless owned
144
+ arguments, error = ToolArguments.canonicalize(arguments)
145
+ return ["$ validation limit exceeded"] if ToolArguments.resource_error?(error)
146
+ return ["$ must be valid JSON"] if error
147
+ end
148
+
149
+ errors = @schema_validator.violations(arguments, owned: true)
150
+ custom_validator = @complete_argument_validator || @argument_validator
151
+ return errors if errors.any? || !custom_validator
152
+
153
+ custom = custom_validator.call(arguments, @input_schema)
154
+ unless custom.is_a?(Array) && custom.all?(String)
155
+ name = @complete_argument_validator ? "complete_argument_validator" : "argument_validator"
156
+ raise TypeError, "#{name} must return an Array of Strings"
157
+ end
158
+
159
+ custom
160
+ end
161
+
73
162
  def invoke(arguments, context)
74
163
  if @handler.lambda? && @handler.arity.between?(0, 1)
75
164
  @handler.arity.zero? ? @handler.call : @handler.call(arguments)