mistri 0.4.0 → 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.
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # Host policy for spawning workers, as one object: the tool pool children
5
+ # may draw from, the curated types, the model allowlist, the headcount
6
+ # cap, and the dispatcher that makes background mode real. #tool builds
7
+ # the spawn_agent tool the top-level agent holds; SubAgent.spawner and
8
+ # SubAgent.pack are the front doors.
9
+ #
10
+ # 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
+ class Spawner
14
+ 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
20
+
21
+ @provider = provider
22
+ @pool = tools
23
+ # Symbol keys are natural Ruby; the wire speaks strings. One
24
+ # normalization here and lookup, schema, and menu all agree.
25
+ @types = types.transform_keys(&:to_s)
26
+ @models = models
27
+ @max_children = max_children
28
+ @dispatcher = dispatcher
29
+ @needs_approval = needs_approval
30
+ @agent_options = agent_options
31
+ validate_types!
32
+ end
33
+
34
+ def tool
35
+ spawner = self
36
+ Tool.define("spawn_agent", description, needs_approval: @needs_approval,
37
+ schema: schema) do |args, context|
38
+ spawner.spawn(args, context)
39
+ end
40
+ end
41
+
42
+ def spawn(args, context)
43
+ crowded = over_capacity(context.session)
44
+ return crowded if crowded
45
+
46
+ worker = resolve_worker(args)
47
+ return worker if worker.is_a?(String)
48
+
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
58
+
59
+ SubAgent.run_child(label: label_for(args), provider: worker[:provider],
60
+ system: worker[:system], tools: worker[:tools],
61
+ task: args.fetch("task"), context: context, **@agent_options)
62
+ end
63
+
64
+ private
65
+
66
+ # 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:
69
+ # what the child's status says after dispatch, not what the mode
70
+ # promised. The runner closes over the spawn-time emit, so in-process
71
+ # dispatchers keep streaming to whoever watched the spawn.
72
+ def dispatch(args, worker, context)
73
+ store = context.session ? context.session.store : Stores::Memory.new
74
+ child = Session.new(store: store)
75
+ label = label_for(args)
76
+ context.session&.append("subagent", "name" => label, "session_id" => child.id)
77
+ child.append(Child::DISPATCHED, {})
78
+ spec = spec_for(args, worker, child, label, context)
79
+ emit = context.emit
80
+ options = @agent_options
81
+ runner = lambda do
82
+ SubAgent.run_dispatched(spec, provider: worker[:provider], system: worker[:system],
83
+ tools: worker[:tools], store: store, emit: emit,
84
+ **options)
85
+ end
86
+ @dispatcher.call(spec, runner)
87
+ receipt(label, child, store)
88
+ end
89
+
90
+ 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" }
98
+ end
99
+
100
+ def receipt(label, child, store)
101
+ status = Child.new(name: label, session_id: child.id, store: store).status
102
+ state = if %i[running queued].include?(status)
103
+ "is working in the background"
104
+ else
105
+ "already finished (#{status})"
106
+ end
107
+ ToolResult.new(
108
+ content: "#{label} #{state} (agent id #{child.id[0, 8]}). Keep working: its " \
109
+ "report will arrive in your context when it finishes. Meanwhile " \
110
+ "read_agent checks on it (wait: true blocks for the report), " \
111
+ "steer_agent adjusts it, stop_agent stops it.",
112
+ ui: { "agent" => label, "session_id" => child.id, "mode" => "background" }
113
+ )
114
+ end
115
+
116
+ # A worker's system prompt, tools, and provider, resolved from its
117
+ # type; a String answers the model in band instead of raising.
118
+ def resolve_worker(args)
119
+ type = args["type"].to_s
120
+ return general_worker(args) if type.empty? || type == "general-purpose"
121
+
122
+ definition = @types[type]
123
+ unless definition
124
+ return "Unknown worker type #{type.inspect}; available: " \
125
+ "#{["general-purpose", *@types.keys].join(", ")}."
126
+ end
127
+
128
+ system = [definition.render, args["instructions"]]
129
+ .reject { |part| part.to_s.strip.empty? }.join("\n\n")
130
+ chosen = args["tools"].nil? || args["tools"].empty? ? definition.tool_names : args["tools"]
131
+ { system: system, tools: pick(chosen),
132
+ provider: typed_provider(args["model"], definition.model) }
133
+ end
134
+
135
+ def general_worker(args)
136
+ system = args["instructions"].to_s
137
+ if system.strip.empty?
138
+ return "A general-purpose worker needs instructions: write its system prompt, " \
139
+ "or pick a type."
140
+ end
141
+
142
+ { system: system, tools: pick(args["tools"]),
143
+ provider: child_provider(args["model"]) }
144
+ end
145
+
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
156
+
157
+ def child_provider(requested)
158
+ return @provider if requested.nil? || requested.to_s.empty?
159
+ unless @models.include?(requested)
160
+ raise ArgumentError,
161
+ "model #{requested.inspect} is not allowed; available: #{@models.join(", ")}"
162
+ end
163
+
164
+ Mistri.provider(requested)
165
+ end
166
+
167
+ def over_capacity(session)
168
+ return nil unless session
169
+
170
+ busy = session.children.count { |child| Child::LIVE.include?(child.status) }
171
+ return nil if busy < @max_children
172
+
173
+ "You already have #{busy} workers running; wait for one to finish or stop one."
174
+ end
175
+
176
+ # Types fail at construction, never mid-spawn: every definition must
177
+ # render without vars (spawn types are self-contained prompts) and
178
+ # declare only tools the pool actually carries.
179
+ def validate_types!
180
+ return if @types.empty?
181
+ if @types.key?("general-purpose")
182
+ raise ConfigurationError, "\"general-purpose\" is the built-in type; pick another name"
183
+ end
184
+
185
+ pool_names = @pool.map(&:name)
186
+ @types.each do |name, definition|
187
+ begin
188
+ definition.render
189
+ rescue ConfigurationError => e
190
+ raise ConfigurationError,
191
+ "type #{name.inspect} cannot be a spawn type: #{e.message}"
192
+ end
193
+ missing = definition.tool_names - pool_names
194
+ unless missing.empty?
195
+ raise ConfigurationError,
196
+ "type #{name.inspect} declares tools the pool lacks: #{missing.join(", ")}"
197
+ end
198
+ end
199
+ end
200
+
201
+ def pick(names)
202
+ return @pool if names.nil? || names.empty?
203
+
204
+ by_name = @pool.to_h { |tool| [tool.name, tool] }
205
+ names.map do |name|
206
+ by_name.fetch(name) do
207
+ raise ArgumentError,
208
+ "unknown tool #{name.inspect}; available: #{by_name.keys.join(", ")}"
209
+ end
210
+ end
211
+ end
212
+
213
+ def schema
214
+ pool_names = @pool.map(&:name)
215
+ type_names = ["general-purpose", *@types.keys]
216
+ types = @types
217
+ models = @models
218
+ dispatcher = @dispatcher
219
+ default = @provider.model if @provider.respond_to?(:model)
220
+ fallback = default ? " (default: #{default})" : ""
221
+ lambda do
222
+ string :name, "A short name for this worker, shown wherever its events appear"
223
+ string :task, "The child's complete task", required: true
224
+ if types.any?
225
+ string :type, "The kind of worker (default: general-purpose)", enum: type_names
226
+ string :instructions, "The worker's system prompt (required for " \
227
+ "general-purpose; appended to a typed worker's own)"
228
+ else
229
+ string :instructions, "The child's system prompt", required: true
230
+ end
231
+ 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 }
234
+ end
235
+ string :model, "Model for the child#{fallback}", enum: models if models.any?
236
+ if dispatcher
237
+ string :mode, "inline blocks until the report; background returns a receipt " \
238
+ "now and you keep working (default: inline)",
239
+ 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
+ end
244
+ end
245
+ end
246
+
247
+ def description
248
+ text = SubAgent::SPAWNER_DESCRIPTION
249
+ unless @types.empty?
250
+ text += " Typed workers come ready-made (#{@types.keys.join(", ")}): their " \
251
+ "instructions, tools, and model are set; add instructions only to " \
252
+ "focus them. general-purpose workers are yours to compose."
253
+ end
254
+ if @dispatcher
255
+ text += " Use background mode when the work is long and you can keep helping " \
256
+ "meanwhile: you get a receipt now, its report arrives on its own when " \
257
+ "the worker finishes, and the console tools manage it in between."
258
+ end
259
+ text
260
+ end
261
+
262
+ def label_for(args)
263
+ SubAgent.sanitize_label(args["name"], fallback: "spawn")
264
+ end
265
+ end
266
+ end
@@ -20,21 +20,36 @@ module Mistri
20
20
  # end
21
21
  # add_index :mistri_entries, [:session_id, :position], unique: true
22
22
  #
23
- # The unique index is load-bearing: one session has one writer at a time
24
- # (the loop is serial), and if a host ever runs two agents on one session,
25
- # a colliding append raises instead of silently corrupting entry order.
23
+ # The unique index is load-bearing: a session's loop is serial, but other
24
+ # writers append alongside it by design (a steer from a web process, a
25
+ # background child's report from a job), so appends are optimistic. Two
26
+ # writers that pick the same position collide on the index and the loser
27
+ # retries at the next one; entry order stays intact without a lock.
26
28
  #
27
29
  # Reads select only (position, payload) and sort in Ruby: ORDER BY over
28
30
  # rows carrying multi-megabyte payloads exhausts MySQL's sort buffer.
29
31
  class ActiveRecord
32
+ # Concurrent writers converge in one or two retries; a session would
33
+ # need this many simultaneous appenders to exhaust them.
34
+ APPEND_ATTEMPTS = 5
35
+
30
36
  def initialize(model)
31
37
  @model = model
32
38
  end
33
39
 
34
40
  def append(id, entry)
35
- position = @model.where(session_id: id).maximum(:position).to_i + 1
36
- @model.create!(session_id: id, position: position, payload: JSON.generate(entry))
37
- nil
41
+ payload = JSON.generate(entry)
42
+ attempts = 0
43
+ begin
44
+ position = @model.where(session_id: id).maximum(:position).to_i + 1
45
+ @model.create!(session_id: id, position: position, payload: payload)
46
+ nil
47
+ rescue ::ActiveRecord::RecordNotUnique
48
+ attempts += 1
49
+ raise if attempts >= APPEND_ATTEMPTS
50
+
51
+ retry
52
+ end
38
53
  end
39
54
 
40
55
  def load(id)
@@ -13,8 +13,10 @@ module Mistri
13
13
  FileUtils.mkdir_p(dir)
14
14
  end
15
15
 
16
+ # One write call per line: concurrent appenders (a steer, a child's
17
+ # report) interleave whole lines, never fragments.
16
18
  def append(id, entry)
17
- File.open(path(id), "a") { |file| file.puts(JSON.generate(entry)) }
19
+ File.write(path(id), "#{JSON.generate(entry)}\n", mode: "a")
18
20
  nil
19
21
  end
20
22