mistri 0.4.1 → 0.5.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.
@@ -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
@@ -24,7 +24,7 @@ module Mistri
24
24
  # spawn = Mistri::SubAgent.spawner(provider:, tools: [fetch_page, search])
25
25
  #
26
26
  # Children never receive a spawn tool: delegation is one level deep by
27
- # construction. Parallel fan-out costs nothing several spawn calls in
27
+ # construction. Parallel fan-out costs nothing: several spawn calls in
28
28
  # one turn run concurrently on the executor pool.
29
29
  #
30
30
  # Child events forward into the parent's stream tagged with origin
@@ -45,7 +45,7 @@ module Mistri
45
45
  attr_reader :name, :description
46
46
 
47
47
  # schema: makes the specialist answer in validated JSON (task mode
48
- # underneath) fan-out children then return a uniform shape the parent
48
+ # underneath), so fan-out children return a uniform shape the parent
49
49
  # synthesizes instead of five styles of prose.
50
50
  def initialize(name:, description:, provider:, system: nil, tools: [], schema: nil,
51
51
  **agent_options)
@@ -62,7 +62,9 @@ module Mistri
62
62
 
63
63
  # The delegate tool: each call runs a fresh child and answers with its
64
64
  # final text, plus {agent, session_id} on the ui channel so a host can
65
- # link the child's transcript.
65
+ # link the child's transcript. The model may name each run, so two
66
+ # parallel researchers read as "Corgi" and "Beagle" in lanes and lists
67
+ # instead of "researcher" twice.
66
68
  def tool
67
69
  sub = self
68
70
  blurb = "#{@description} Runs as a focused sub-agent with a clean " \
@@ -71,39 +73,41 @@ module Mistri
71
73
  schema: lambda {
72
74
  string :task, "Complete instructions for the sub-agent",
73
75
  required: true
76
+ string :name, "A short name for this run, shown wherever " \
77
+ "its events appear (default: the tool's name)"
74
78
  }) do |args, context|
75
- sub.run_child(args.fetch("task"), context)
79
+ sub.run_child(args.fetch("task"), context, name: args["name"])
76
80
  end
77
81
  end
78
82
 
79
- def run_child(task, context)
80
- SubAgent.run_child(label: @name, provider: @provider, system: @system,
83
+ def run_child(task, context, name: nil)
84
+ SubAgent.run_child(label: SubAgent.sanitize_label(name, fallback: @name),
85
+ provider: @provider, system: @system,
81
86
  tools: @tools, task: task, context: context, schema: @schema,
82
87
  **@agent_options)
83
88
  end
84
89
 
85
90
  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
91
+ # The open spawn tool: the model names each worker, grants it a tool
92
+ # subset from the host's pool, and may pick a type, a model, and a
93
+ # mode. All policy lives on Spawner; this is the front door.
94
+ def spawner(provider:, **)
95
+ Spawner.new(provider: provider, **).tool
96
+ end
97
97
 
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
98
+ # The whole kit in one call: the spawn tool plus the management
99
+ # console, so a host hands its agent everything workers need.
100
+ def pack(provider:, console: {}, **spawner_options)
101
+ [spawner(provider: provider, **spawner_options), *Console.tools(**console)]
102
+ end
103
+
104
+ # A worker's display name, made safe for origins: the label rides
105
+ # them as "label#id" and nesting joins with ">", so those separators
106
+ # squeeze to hyphens along with whitespace. Blank falls back.
107
+ def sanitize_label(text, fallback:)
108
+ label = text.to_s.gsub(/[#>\s]+/, "-").squeeze("-")[0, 32]
109
+ label = label.delete_prefix("-").delete_suffix("-")
110
+ label.empty? ? fallback : label
107
111
  end
108
112
 
109
113
  def run_child(label:, provider:, system:, tools:, task:, context:, schema: nil,
@@ -111,16 +115,103 @@ module Mistri
111
115
  store = context.session ? context.session.store : Stores::Memory.new
112
116
  child = Session.new(store: store)
113
117
  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)
120
- else
121
- agent.run(task, signal: context.signal, &emit)
122
- end
123
- answer(result, label, child)
118
+ # An inline child runs on a signal derived from the parent's: the
119
+ # parent's abort cascades down through the handle, while stopping
120
+ # the child alone leaves the parent running.
121
+ signal, cascade = context.signal ? context.signal.derive : [AbortSignal.new, nil]
122
+ result = begin
123
+ execute_child(child: child, label: label, provider: provider, system: system,
124
+ tools: tools, task: task, schema: schema, signal: signal,
125
+ emit: context.emit, **agent_options)
126
+ ensure
127
+ context.signal&.remove_callback(cascade) if cascade
128
+ end
129
+ outcome = answer(result, label, child)
130
+ child.append(Child::TERMINAL, terminal(result))
131
+ outcome
132
+ end
133
+
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.
142
+ #
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)
153
+ child = Session.new(store: store, id: spec.fetch("session_id"))
154
+ signal = AbortSignal.new
155
+ lease = Locks.hold(Child.lease_key(child.id),
156
+ stop_key: Child.stop_key(child.id), signal: signal)
157
+ return nil if Mistri.locks && lease.nil?
158
+
159
+ if Child.new(name: spec.fetch("name"), session_id: child.id, store: store).finished?
160
+ lease&.release
161
+ return nil
162
+ end
163
+
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)
168
+ deny_pending(result, child)
169
+ child.append(Child::TERMINAL, terminal(result))
170
+ result
171
+ rescue StandardError => e
172
+ # Completion is a contract even when the runner dies in the
173
+ # preamble: without this, a raise before the started entry (a lock
174
+ # backend down, say) would leave the child reading :queued forever,
175
+ # 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}")
180
+ end
181
+ lease&.release
182
+ raise
183
+ ensure
184
+ report_back(spec, store, emit) if child
185
+ end
186
+
187
+ # A child cannot wait for a human, whichever door it entered by: any
188
+ # calls parked for approval are denied AND settled with the denial as
189
+ # their tool result, so no approval request stays open on a finished
190
+ # child and its transcript replays without repair.
191
+ def deny_pending(result, child)
192
+ return unless result.awaiting_approval?
193
+
194
+ result.pending.each do |call|
195
+ child.deny(call.id, note: "sub-agents cannot pause for human approval")
196
+ child.append_message(Message.tool(
197
+ content: "Denied: sub-agents cannot pause for human approval.",
198
+ tool_call_id: call.id, tool_name: call.name
199
+ ))
200
+ end
201
+ end
202
+
203
+ # Every child ends by writing its own terminal entry: completion is a
204
+ # contract, and status stays readable from the store forever.
205
+ def terminal(result)
206
+ case result.status
207
+ when :completed then { "status" => "done", "report" => result.text.to_s }
208
+ when :aborted then { "status" => "stopped" }
209
+ when :awaiting_approval
210
+ { "status" => "failed",
211
+ "error" => "needed human approval, which sub-agents cannot wait for" }
212
+ else
213
+ { "status" => "failed", "error" => (result.error_message || result.status).to_s }
214
+ end
124
215
  end
125
216
 
126
217
  def forbid_gated!(tools)
@@ -134,11 +225,69 @@ module Mistri
134
225
 
135
226
  private
136
227
 
137
- def forward(event, origin, context)
138
- return unless context.emit
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
139
262
 
140
263
  tagged = event.origin ? "#{origin}>#{event.origin}" : origin
141
- context.emit.call(event.with(origin: tagged))
264
+ emit.call(event.with(origin: tagged))
265
+ end
266
+
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
270
+ # closes the child's lane in whatever UI watched the spawn. A child
271
+ # that never ran has nothing to say, and the parent session drops a
272
+ # duplicate delivery, so a redelivered job cannot repeat one.
273
+ def report_back(spec, store, emit)
274
+ facade = Child.new(name: spec.fetch("name"), session_id: spec.fetch("session_id"),
275
+ store: store)
276
+ return unless facade.finished?
277
+
278
+ status = facade.status
279
+ text = status == :failed ? facade.error : facade.report
280
+ delivered = if (parent_id = spec["parent_session_id"])
281
+ Session.new(store: store, id: parent_id)
282
+ .deliver_report(name: facade.name, session_id: facade.session_id,
283
+ status: status.to_s, text: text)
284
+ else
285
+ true
286
+ end
287
+ return unless delivered
288
+
289
+ emit&.call(Event.new(type: :subagent_report, agent: facade.name,
290
+ session_id: facade.session_id, status: status, content: text))
142
291
  end
143
292
 
144
293
  # The parent always gets an in-band answer it can react to. A child
@@ -150,68 +299,17 @@ module Mistri
150
299
  when :completed
151
300
  ToolResult.new(content: result.text.to_s, ui: link)
152
301
  when :awaiting_approval
153
- result.pending.each do |call|
154
- child.deny(call.id, note: "sub-agents cannot pause for human approval")
155
- end
302
+ deny_pending(result, child)
156
303
  ToolResult.new(content: "The #{label} sub-agent stopped: it needed human " \
157
304
  "approval, which sub-agents cannot wait for.", ui: link)
158
305
  when :aborted
159
- ToolResult.new(content: "[the #{label} sub-agent was aborted]", ui: link)
306
+ ToolResult.new(content: "[the #{label} sub-agent was stopped]", ui: link)
160
307
  else
161
308
  reason = result.error_message || result.status
162
309
  ToolResult.new(content: "The #{label} sub-agent failed: #{reason}", ui: link)
163
310
  end
164
311
  end
165
312
 
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
175
- end
176
- end
177
-
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
313
  # A predicate gate cannot be judged statically; the runtime denial in
216
314
  # answer covers it.
217
315
  def statically_gated?(tool)
@@ -0,0 +1,40 @@
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
+
13
+ module_function
14
+
15
+ def prompt(input, schema)
16
+ "#{input}\n\nAnswer with ONLY a JSON value matching this schema:\n" \
17
+ "#{JSON.generate(Schema.strict(schema))}"
18
+ end
19
+
20
+ def parse(text)
21
+ body = text.to_s.strip
22
+ body = body[/\A```(?:json)?\s*(.*?)```\z/m, 1] || body
23
+ JSON.parse(body)
24
+ rescue JSON::ParserError
25
+ PARSE_FAILED
26
+ end
27
+
28
+ def errors(value, schema)
29
+ return ["the answer is not valid JSON"] if value.equal?(PARSE_FAILED)
30
+
31
+ Schema.violations(value, schema)
32
+ end
33
+
34
+ def fix_prompt(errors)
35
+ lines = errors.map { |error| "- #{error}" }.join("\n")
36
+ "Your answer did not satisfy the required output schema. Problems:\n" \
37
+ "#{lines}\nReply with ONLY the corrected JSON."
38
+ end
39
+ end
40
+ end
data/lib/mistri/tool.rb CHANGED
@@ -28,7 +28,7 @@ module Mistri
28
28
  end
29
29
 
30
30
  def initialize(name:, description:, input_schema: EMPTY_SCHEMA, eager_input_streaming: false,
31
- needs_approval: false, timeout: nil, &handler)
31
+ needs_approval: false, ends_turn: false, timeout: nil, &handler)
32
32
  raise ArgumentError, "tool #{name.inspect} needs a handler block" unless handler
33
33
 
34
34
  @name = name.to_s
@@ -36,6 +36,7 @@ module Mistri
36
36
  @input_schema = input_schema
37
37
  @eager_input_streaming = eager_input_streaming
38
38
  @needs_approval = needs_approval
39
+ @ends_turn = ends_turn
39
40
  @timeout = timeout
40
41
  @handler = handler
41
42
  end
@@ -61,6 +62,14 @@ module Mistri
61
62
  @needs_approval.respond_to?(:call) ? @needs_approval.call(arguments) : @needs_approval
62
63
  end
63
64
 
65
+ # A tool that is the last word of its turn: once it executes, the loop
66
+ # ends the run instead of prompting the model again. This is how a tool
67
+ # like ask_user hands the floor to a human structurally, with no prompt
68
+ # discipline required; the answer arrives as the next run's input.
69
+ def ends_turn?
70
+ @ends_turn
71
+ end
72
+
64
73
  # The provider-facing definition; every serializer accepts this shape.
65
74
  def spec
66
75
  definition = { name: @name, description: @description, input_schema: @input_schema }
@@ -3,14 +3,14 @@
3
3
  module Mistri
4
4
  # What a tool handler may know about the run it executes inside: the
5
5
  # caller's session, the abort signal, the event stream, and the host's
6
- # own context object. Handlers take it as an optional second argument a
6
+ # own context object. Handlers take it as an optional second argument: a
7
7
  # proc ignores it invisibly, a lambda opts in by accepting two
8
8
  # parameters. Sub-agents are built on it; any tool that spawns work,
9
9
  # links records to the session, or streams progress can use it the same
10
10
  # way.
11
11
  #
12
- # app carries whatever the host passes as Agent.new(context:) the
13
- # acting user, a tenant, a request untouched. The gem provides the
12
+ # app carries whatever the host passes as Agent.new(context:) (the
13
+ # acting user, a tenant, a request), untouched. The gem provides the
14
14
  # slot, never the vocabulary:
15
15
  #
16
16
  # agent = Mistri.agent("claude-opus-4-8", tools: tools,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mistri
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/mistri.rb CHANGED
@@ -14,6 +14,7 @@ require_relative "mistri/sse"
14
14
  require_relative "mistri/partial_json"
15
15
  require_relative "mistri/transport"
16
16
  require_relative "mistri/schema"
17
+ require_relative "mistri/task_output"
17
18
  require_relative "mistri/edit"
18
19
  require_relative "mistri/tool_context"
19
20
  require_relative "mistri/tool_result"
@@ -34,10 +35,15 @@ require_relative "mistri/compaction"
34
35
  require_relative "mistri/stores/memory"
35
36
  require_relative "mistri/stores/jsonl"
36
37
  require_relative "mistri/session"
38
+ require_relative "mistri/child"
39
+ require_relative "mistri/locks"
40
+ require_relative "mistri/console"
41
+ require_relative "mistri/dispatchers"
37
42
  require_relative "mistri/compactor"
38
43
  require_relative "mistri/result"
39
44
  require_relative "mistri/agent"
40
45
  require_relative "mistri/sub_agent"
46
+ require_relative "mistri/spawner"
41
47
  require_relative "mistri/mcp"
42
48
  require_relative "mistri/sinks/action_cable"
43
49
  require_relative "mistri/sinks/sse"
@@ -54,6 +60,11 @@ module Mistri
54
60
  API_KEY_ENV = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY",
55
61
  gemini: "GEMINI_API_KEY" }.freeze
56
62
 
63
+ # The configured lock adapter, nil until a host sets one. See Locks.
64
+ class << self
65
+ attr_accessor :locks
66
+ end
67
+
57
68
  module_function
58
69
 
59
70
  # Build a provider for a model, inferring which one from the model id and
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mistri
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Muhammad Ahmed Cheema
@@ -31,13 +31,18 @@ files:
31
31
  - lib/mistri/abort_signal.rb
32
32
  - lib/mistri/agent.rb
33
33
  - lib/mistri/budget.rb
34
+ - lib/mistri/child.rb
34
35
  - lib/mistri/compaction.rb
35
36
  - lib/mistri/compactor.rb
37
+ - lib/mistri/console.rb
36
38
  - lib/mistri/content.rb
37
39
  - lib/mistri/definition.rb
40
+ - lib/mistri/dispatchers.rb
38
41
  - lib/mistri/edit.rb
39
42
  - lib/mistri/errors.rb
40
43
  - lib/mistri/event.rb
44
+ - lib/mistri/locks.rb
45
+ - lib/mistri/locks/rails_cache.rb
41
46
  - lib/mistri/mcp.rb
42
47
  - lib/mistri/mcp/client.rb
43
48
  - lib/mistri/mcp/oauth.rb
@@ -66,12 +71,14 @@ files:
66
71
  - lib/mistri/sinks/sse.rb
67
72
  - lib/mistri/skill.rb
68
73
  - lib/mistri/skills.rb
74
+ - lib/mistri/spawner.rb
69
75
  - lib/mistri/sse.rb
70
76
  - lib/mistri/stop_reason.rb
71
77
  - lib/mistri/stores/active_record.rb
72
78
  - lib/mistri/stores/jsonl.rb
73
79
  - lib/mistri/stores/memory.rb
74
80
  - lib/mistri/sub_agent.rb
81
+ - lib/mistri/task_output.rb
75
82
  - lib/mistri/tool.rb
76
83
  - lib/mistri/tool_call.rb
77
84
  - lib/mistri/tool_context.rb