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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +132 -4
- data/README.md +88 -7
- data/lib/mistri/abort_signal.rb +10 -0
- data/lib/mistri/agent.rb +70 -63
- 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 +9 -3
- data/lib/mistri/locks/rails_cache.rb +48 -0
- data/lib/mistri/locks.rb +141 -0
- 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 +2 -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
data/lib/mistri/retry_policy.rb
CHANGED
|
@@ -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
|
|
8
|
-
# requests, our own bugs
|
|
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
|
data/lib/mistri/session.rb
CHANGED
|
@@ -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
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
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
|
-
|
|
82
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|