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.
@@ -4,8 +4,8 @@ module Mistri
4
4
  # When a failed turn is worth retrying, and how long to wait. Transient
5
5
  # failures (rate limits, overload, server errors, timeouts, dropped or
6
6
  # truncated streams) retry with jittered exponential backoff, honoring the
7
- # provider's retry-after when it sent one. Everything else auth, invalid
8
- # requests, our own bugs fails fast.
7
+ # provider's retry-after when it sent one. Everything else (auth, invalid
8
+ # requests, our own bugs) fails fast.
9
9
  #
10
10
  # attempts counts retries, not calls: attempts 3 means up to four requests.
11
11
  class RetryPolicy
@@ -66,6 +66,11 @@ module Mistri
66
66
  entries.reverse_each.find { |entry| entry["type"] == "compaction" }
67
67
  end
68
68
 
69
+ # The inbox: entry types queued for the loop's next turn boundary, each
70
+ # mapped to the marker key its fold leaves on the consuming message
71
+ # entry.
72
+ INBOX = { "steer" => "steer_id", "subagent_report" => "report_id" }.freeze
73
+
69
74
  # Queue a message for a running exchange from any process. The loop folds
70
75
  # pending steers into the transcript at the next turn boundary, so the
71
76
  # model sees them mid-run; one that arrives as the model finishes cleanly
@@ -74,12 +79,64 @@ module Mistri
74
79
  append("steer", "id" => SecureRandom.uuid, "message" => Message.user(text).to_h)
75
80
  end
76
81
 
77
- # Steers not yet folded into the transcript, oldest first. The folding
78
- # message entry carries the steer id, so consumption is derived from the
79
- # log alone and reads the same from every process.
82
+ # A sub-agent's report, queued for this session the way a steer is: it
83
+ # folds into the transcript at the next turn boundary as a labeled block
84
+ # the model can react to ("[Magpie finished] <report>"), while the typed
85
+ # entry keeps name, status, and the raw text for hosts to render as a
86
+ # report card rather than a fake user message. One report per child,
87
+ # ever: a duplicate delivery (a redelivered queue job, a lease race) is
88
+ # dropped, and the return says which happened. Reports normally arrive
89
+ # via SubAgent.run_dispatched; call this directly only from a custom
90
+ # dispatch path.
91
+ def deliver_report(name:, session_id:, status:, text: nil) # rubocop:disable Naming/PredicateMethod
92
+ already = entries.any? do |entry|
93
+ entry["type"] == "subagent_report" && entry["session_id"] == session_id
94
+ end
95
+ return false if already
96
+
97
+ append("subagent_report", "id" => SecureRandom.uuid, "name" => name,
98
+ "session_id" => session_id, "status" => status, "report" => text,
99
+ "message" => Message.user(report_label(name, status, text)).to_h)
100
+ true
101
+ end
102
+
103
+ # Everything queued for the loop's next turn boundary (steers and
104
+ # sub-agent reports), oldest first, in arrival order. The folding
105
+ # message entry carries the source entry's id under its marker key, so
106
+ # consumption is derived from the log alone and reads the same from
107
+ # every process. A host that wakes an idle session when a steer arrives
108
+ # should watch this instead: a report deserves the same pickup.
109
+ def pending_inbox
110
+ log = entries
111
+ folded = log.flat_map { |entry| entry.values_at(*INBOX.values) }.compact.to_set
112
+ log.select { |entry| INBOX.key?(entry["type"]) && !folded.include?(entry["id"]) }
113
+ end
114
+
115
+ # The steer-only slice of the inbox, oldest first.
80
116
  def pending_steers
81
- folded = entries.filter_map { |entry| entry["steer_id"] }
82
- entries.select { |entry| entry["type"] == "steer" && !folded.include?(entry["id"]) }
117
+ pending_inbox.select { |entry| entry["type"] == "steer" }
118
+ end
119
+
120
+ # Sub-agents this session has spawned, in spawn order: one Child window
121
+ # per link entry, each reading the child's own session. Derived from the
122
+ # log alone, like everything else here.
123
+ def children
124
+ entries.filter_map do |entry|
125
+ next unless entry["type"] == "subagent"
126
+
127
+ Child.new(name: entry["name"], session_id: entry["session_id"], store: @store)
128
+ end
129
+ end
130
+
131
+ # The session as a reader renders it: entries in order with inline image
132
+ # bytes stripped, and, with include_children, every sub-agent's own
133
+ # transcript spliced in after its link entry. Spliced entries carry an
134
+ # "origin" key shaped exactly like the live stream's event origins
135
+ # ("Magpie#ab12cd34", nesting joined with ">"), so a UI that rebuilds
136
+ # from this sees what it saw live, lanes included, running children's
137
+ # progress-so-far included. The raw log stays available as #entries.
138
+ def transcript(include_children: false)
139
+ splice(entries, include_children: include_children, prefix: nil, seen: Set[@id])
83
140
  end
84
141
 
85
142
  # Record a human's decision on a parked tool call. Decisions are session
@@ -137,6 +194,39 @@ module Mistri
137
194
  Message.user("#{Compaction::SUMMARY_PREFACE}\n\n#{summary}")
138
195
  end
139
196
 
197
+ # Each link entry opens its child's log in place, depth first, the way
198
+ # nested lanes opened live. The seen set makes expansion idempotent per
199
+ # child (a repeated or self-referencing link renders but never expands
200
+ # twice), so a hostile log cannot loop this.
201
+ def splice(log, include_children:, prefix:, seen:)
202
+ log.flat_map do |entry|
203
+ rendered = Child.strip_images(entry)
204
+ rendered = rendered.merge("origin" => prefix) if prefix
205
+ next [rendered] unless include_children && expandable?(entry, seen)
206
+
207
+ seen << entry["session_id"]
208
+ origin = "#{entry["name"]}##{entry["session_id"][0, 8]}"
209
+ origin = "#{prefix}>#{origin}" if prefix
210
+ child_log = self.class.new(store: @store, id: entry["session_id"]).entries
211
+ [rendered, *splice(child_log, include_children: true, prefix: origin, seen: seen)]
212
+ end
213
+ end
214
+
215
+ def expandable?(entry, seen)
216
+ entry["type"] == "subagent" && !seen.include?(entry["session_id"])
217
+ end
218
+
219
+ # How a report reads to the model: labeled with the worker's name and
220
+ # fate, so the parent knows exactly who finished and how.
221
+ def report_label(name, status, text)
222
+ case status
223
+ when "done" then "[#{name} finished] #{text}"
224
+ when "failed" then "[#{name} failed] #{text}"
225
+ when "stopped" then "[#{name} was stopped]"
226
+ else "[#{name} ended: #{status}]"
227
+ end
228
+ end
229
+
140
230
  def decide(call_id, approved:, note:)
141
231
  unless open_approvals.any? { |open| open[:call].id == call_id }
142
232
  raise ConfigurationError, "no open approval for #{call_id.inspect}"
data/lib/mistri/skill.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  module Mistri
4
4
  # One expert playbook: a name the model selects by, a description that
5
5
  # earns the selection, and the full body it reads before acting. Build
6
- # them from anywhere Skills.load reads a directory, and a host with
6
+ # them from anywhere: Skills.load reads a directory, and a host with
7
7
  # skills in a database constructs these directly.
8
8
  Skill = Data.define(:name, :description, :body) do
9
9
  def initialize(name:, description: "", body: "")
data/lib/mistri/skills.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  module Mistri
4
4
  # Loads skills and wires them into an agent: their one-line descriptions
5
5
  # ride the system prompt, and the model pulls a full body on demand with
6
- # the read_skill tool so a large library costs almost nothing until a
6
+ # the read_skill tool, so a large library costs almost nothing until a
7
7
  # skill is actually used.
8
8
  module Skills
9
9
  module_function
@@ -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