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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +152 -4
- data/README.md +111 -7
- data/lib/mistri/abort_signal.rb +10 -0
- data/lib/mistri/agent.rb +85 -90
- data/lib/mistri/child.rb +130 -0
- data/lib/mistri/compactor.rb +1 -1
- data/lib/mistri/console.rb +188 -0
- data/lib/mistri/dispatchers.rb +47 -0
- data/lib/mistri/event.rb +15 -5
- data/lib/mistri/locks/rails_cache.rb +48 -0
- data/lib/mistri/locks.rb +141 -0
- data/lib/mistri/mcp/client.rb +1 -1
- data/lib/mistri/mcp.rb +1 -1
- data/lib/mistri/providers/fake.rb +16 -2
- data/lib/mistri/result.rb +7 -2
- data/lib/mistri/retry_policy.rb +24 -2
- data/lib/mistri/session.rb +95 -5
- data/lib/mistri/skill.rb +1 -1
- data/lib/mistri/skills.rb +1 -1
- data/lib/mistri/spawner.rb +266 -0
- data/lib/mistri/stores/active_record.rb +21 -6
- data/lib/mistri/stores/jsonl.rb +3 -1
- data/lib/mistri/sub_agent.rb +191 -93
- data/lib/mistri/task_output.rb +40 -0
- data/lib/mistri/tool.rb +10 -1
- data/lib/mistri/tool_context.rb +3 -3
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri.rb +11 -0
- metadata +8 -1
|
@@ -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:
|
|
24
|
-
#
|
|
25
|
-
#
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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)
|
data/lib/mistri/stores/jsonl.rb
CHANGED
|
@@ -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.
|
|
19
|
+
File.write(path(id), "#{JSON.generate(entry)}\n", mode: "a")
|
|
18
20
|
nil
|
|
19
21
|
end
|
|
20
22
|
|