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
data/lib/mistri/agent.rb
CHANGED
|
@@ -13,8 +13,12 @@ module Mistri
|
|
|
13
13
|
# marked needs_approval suspends the run instead of executing: the run
|
|
14
14
|
# returns at once (no thread ever waits on a human), the decision arrives
|
|
15
15
|
# later as a session entry from any process, and resume settles it and
|
|
16
|
-
# carries on.
|
|
17
|
-
#
|
|
16
|
+
# carries on. A tool marked ends_turn ends the run once it executes: the
|
|
17
|
+
# model does not get another word, and whatever comes next (a human's
|
|
18
|
+
# answer to ask_user, say) arrives as the next run's input. Session#steer
|
|
19
|
+
# queues a user message from any process while the loop runs; it folds
|
|
20
|
+
# into the transcript at the next turn boundary, and a background child's
|
|
21
|
+
# report arrives through the same inbox.
|
|
18
22
|
class Agent
|
|
19
23
|
# compaction defaults on so long sessions survive their context window;
|
|
20
24
|
# pass false to disable, or a tuned Compaction. It only ever triggers
|
|
@@ -52,12 +56,6 @@ module Mistri
|
|
|
52
56
|
|
|
53
57
|
attr_reader :session
|
|
54
58
|
|
|
55
|
-
# What retries see when a completion answers nothing at all.
|
|
56
|
-
EMPTY_COMPLETION_ERROR = {
|
|
57
|
-
"type" => "EmptyCompletion",
|
|
58
|
-
"message" => "the provider returned an empty completion"
|
|
59
|
-
}.freeze
|
|
60
|
-
|
|
61
59
|
# Run one exchange: append the user turn, then loop until the model
|
|
62
60
|
# answers without tools, a gated tool suspends the run, the run aborts,
|
|
63
61
|
# or a budget stops it.
|
|
@@ -66,13 +64,15 @@ module Mistri
|
|
|
66
64
|
# on top; run alone does not validate.
|
|
67
65
|
def run(input, images: [], signal: nil, output_schema: nil, &emit)
|
|
68
66
|
if @session.open_approvals.any?
|
|
69
|
-
raise ConfigurationError,
|
|
67
|
+
raise ConfigurationError,
|
|
68
|
+
"session is awaiting approvals; call resume"
|
|
70
69
|
end
|
|
71
70
|
if input.to_s.empty? && Array(images).empty?
|
|
72
|
-
raise ArgumentError,
|
|
71
|
+
raise ArgumentError,
|
|
72
|
+
"run needs input text or images"
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
fold_inbox # anything queued while this session sat idle arrived first; keep that order
|
|
76
76
|
@session.append_message(Message.user_with_images(input, images))
|
|
77
77
|
loop_turns(signal, output_schema, &emit)
|
|
78
78
|
end
|
|
@@ -80,7 +80,8 @@ module Mistri
|
|
|
80
80
|
# Continue a suspended run. Undecided approvals return immediately, still
|
|
81
81
|
# suspended. Decided ones settle first: approved calls execute, denied
|
|
82
82
|
# calls answer in band so the model knows and can react. Then the loop
|
|
83
|
-
# carries on as if it never stopped
|
|
83
|
+
# carries on as if it never stopped, unless a settled call's tool ends
|
|
84
|
+
# the turn, in which case its execution was the run's last word.
|
|
84
85
|
def resume(signal: nil, &emit)
|
|
85
86
|
open = @session.open_approvals
|
|
86
87
|
pending = open.select { |approval| approval[:decision].nil? }
|
|
@@ -90,7 +91,12 @@ module Mistri
|
|
|
90
91
|
usage: Usage.zero)
|
|
91
92
|
end
|
|
92
93
|
|
|
93
|
-
settle(open, signal, &emit)
|
|
94
|
+
executed = settle(open, signal, &emit)
|
|
95
|
+
if executed.any? { |call| ends_turn?(call) }
|
|
96
|
+
last = @session.messages.reverse_each.find(&:assistant?)
|
|
97
|
+
return finished(last, Usage.zero, signal, handed_off: true)
|
|
98
|
+
end
|
|
99
|
+
|
|
94
100
|
loop_turns(signal, nil, &emit)
|
|
95
101
|
end
|
|
96
102
|
|
|
@@ -102,21 +108,24 @@ module Mistri
|
|
|
102
108
|
#
|
|
103
109
|
# A run that suspends for approval returns as-is: validation applies to
|
|
104
110
|
# completed runs only, so resume the session and re-ask if that happens
|
|
105
|
-
# mid-task.
|
|
111
|
+
# mid-task. A run an ends_turn tool ended returns as-is too: the floor
|
|
112
|
+
# belongs to whoever answers, and re-prompting for JSON would steal it
|
|
113
|
+
# back. Ask again once the answer arrives.
|
|
106
114
|
def task(input, schema:, images: [], signal: nil, fixes: 1, &emit)
|
|
107
|
-
result = run(
|
|
108
|
-
|
|
115
|
+
result = run(TaskOutput.prompt(input, schema), images: images, signal: signal,
|
|
116
|
+
output_schema: schema, &emit)
|
|
109
117
|
spent = result.usage
|
|
110
118
|
fixes.downto(0) do |remaining|
|
|
111
119
|
result = result.with(usage: spent)
|
|
112
120
|
return result unless result.completed?
|
|
121
|
+
return result if result.handed_off?
|
|
113
122
|
|
|
114
|
-
value =
|
|
115
|
-
errors =
|
|
123
|
+
value = TaskOutput.parse(result.text)
|
|
124
|
+
errors = TaskOutput.errors(value, schema)
|
|
116
125
|
return result.with(output: value) if errors.empty?
|
|
117
126
|
raise SchemaError, "task output failed validation: #{errors.join("; ")}" if remaining.zero?
|
|
118
127
|
|
|
119
|
-
result = run(fix_prompt(errors), signal: signal, output_schema: schema, &emit)
|
|
128
|
+
result = run(TaskOutput.fix_prompt(errors), signal: signal, output_schema: schema, &emit)
|
|
120
129
|
spent += result.usage
|
|
121
130
|
end
|
|
122
131
|
end
|
|
@@ -148,7 +157,7 @@ module Mistri
|
|
|
148
157
|
reason = @budget.exceeded(turns: turns, usage: usage, elapsed: monotonic_now - started)
|
|
149
158
|
return stop_for_budget(reason, usage, &emit) if reason
|
|
150
159
|
|
|
151
|
-
|
|
160
|
+
fold_inbox
|
|
152
161
|
compacted = auto_compact(&emit)
|
|
153
162
|
usage += compacted[:usage] if compacted&.dig(:usage)
|
|
154
163
|
last = run_turn(signal, output_schema, &emit)
|
|
@@ -157,20 +166,21 @@ module Mistri
|
|
|
157
166
|
|
|
158
167
|
# Any tool call the turn made must be answered or parked, or the
|
|
159
168
|
# transcript is unpairable and replay fails.
|
|
160
|
-
parked = last.tool_calls? ? run_tools(last, signal, &emit) : []
|
|
169
|
+
parked, ended = last.tool_calls? ? run_tools(last, signal, &emit) : [[], false]
|
|
161
170
|
return suspended(last, parked, usage) if parked.any?
|
|
162
|
-
return finished(last, usage) if done?(last, signal)
|
|
171
|
+
return finished(last, usage, signal, handed_off: ended) if ended || done?(last, signal)
|
|
163
172
|
end
|
|
164
173
|
end
|
|
165
174
|
|
|
166
|
-
# A steer that lands while the model finishes
|
|
167
|
-
# more turn so it
|
|
168
|
-
#
|
|
175
|
+
# A steer or a child's report that lands while the model finishes
|
|
176
|
+
# cleanly extends the run one more turn, so it is answered rather than
|
|
177
|
+
# left dangling. Aborts, errors, and length stops always end the run;
|
|
178
|
+
# the inbox stays pending for the next one.
|
|
169
179
|
def done?(last, signal)
|
|
170
180
|
return false if last.stop_reason == StopReason::TOOL_USE && !signal&.aborted?
|
|
171
181
|
return true if signal&.aborted? || last.stop_reason != StopReason::STOP
|
|
172
182
|
|
|
173
|
-
@session.
|
|
183
|
+
@session.pending_inbox.empty?
|
|
174
184
|
end
|
|
175
185
|
|
|
176
186
|
# Compact when the context has grown into the reserve. A failed
|
|
@@ -191,13 +201,15 @@ module Mistri
|
|
|
191
201
|
@compaction&.window || Models.find(@provider.model)&.context_window
|
|
192
202
|
end
|
|
193
203
|
|
|
194
|
-
# Materialize queued steers
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
204
|
+
# Materialize the inbox, queued steers and sub-agent reports alike, into
|
|
205
|
+
# the transcript in arrival order. The folded message entry carries the
|
|
206
|
+
# source entry's id under its marker key, which is what marks the entry
|
|
207
|
+
# consumed: one append is both the fold and the marker, so a crash
|
|
208
|
+
# between folds never double-delivers.
|
|
209
|
+
def fold_inbox
|
|
210
|
+
@session.pending_inbox.each do |entry|
|
|
211
|
+
marker = Session::INBOX.fetch(entry["type"])
|
|
212
|
+
@session.append("message", "message" => entry["message"], marker => entry["id"])
|
|
201
213
|
end
|
|
202
214
|
end
|
|
203
215
|
|
|
@@ -208,44 +220,35 @@ module Mistri
|
|
|
208
220
|
# the request.
|
|
209
221
|
#
|
|
210
222
|
# A transient failure retries the same request with backoff; the failed
|
|
211
|
-
# attempt
|
|
212
|
-
#
|
|
223
|
+
# attempt records as a retry entry, never as a message, and its terminal
|
|
224
|
+
# (:done or :error) holds at a gate, so retries stay invisible to the
|
|
225
|
+
# model and a recovered retry never shows the subscriber an error it
|
|
226
|
+
# walks back. Only the accepted attempt persists and terminates.
|
|
213
227
|
def run_turn(signal, output_schema = nil, &emit)
|
|
214
228
|
history = @transform_context.reduce(@session.messages) do |messages, transform|
|
|
215
229
|
transform.call(messages)
|
|
216
230
|
end
|
|
217
231
|
attempt = 0
|
|
218
232
|
loop do
|
|
233
|
+
held = nil
|
|
234
|
+
gate = emit && ->(event) { event.terminal? ? held = event : emit.call(event) }
|
|
219
235
|
message = @provider.stream(messages: history, system: @system,
|
|
220
236
|
tools: @tools.map(&:spec), signal: signal,
|
|
221
|
-
output_schema: output_schema, &
|
|
237
|
+
output_schema: output_schema, &gate)
|
|
222
238
|
attempt += 1
|
|
223
|
-
error =
|
|
239
|
+
error = @retries&.error_for(message)
|
|
224
240
|
if retry_turn?(error, attempt, signal)
|
|
225
241
|
pause = @retries.delay(attempt, error["retry_after"])
|
|
226
242
|
record_retry(message, error, attempt, pause, &emit)
|
|
227
243
|
wait(pause, signal)
|
|
228
244
|
next unless signal&.aborted?
|
|
229
245
|
end
|
|
246
|
+
emit&.call(held) if held
|
|
230
247
|
@session.append_message(message)
|
|
231
248
|
return message
|
|
232
249
|
end
|
|
233
250
|
end
|
|
234
251
|
|
|
235
|
-
# The provider's own error when the turn failed, or a synthesized one
|
|
236
|
-
# for a completion that answers nothing: providers intermittently stop
|
|
237
|
-
# with an empty candidate, and a retry usually clears it.
|
|
238
|
-
def turn_error(message)
|
|
239
|
-
return message.error if message.stop_reason == StopReason::ERROR
|
|
240
|
-
|
|
241
|
-
EMPTY_COMPLETION_ERROR if empty_completion?(message)
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
def empty_completion?(message)
|
|
245
|
-
message.stop_reason == StopReason::STOP &&
|
|
246
|
-
message.content.all? { |block| block.is_a?(Content::Text) && block.text.to_s.strip.empty? }
|
|
247
|
-
end
|
|
248
|
-
|
|
249
252
|
def retry_turn?(error, attempt, signal)
|
|
250
253
|
return false unless @retries && error
|
|
251
254
|
return false if signal&.aborted?
|
|
@@ -258,7 +261,8 @@ module Mistri
|
|
|
258
261
|
"delay" => pause.round(2))
|
|
259
262
|
note = format("attempt %<attempt>d failed; retrying in %<pause>.1fs",
|
|
260
263
|
attempt: attempt, pause: pause)
|
|
261
|
-
emit&.call(Event.new(type: :retry, content: note,
|
|
264
|
+
emit&.call(Event.new(type: :retry, content: note, attempt: attempt,
|
|
265
|
+
max_attempts: @retries.attempts, delay: pause.round(2),
|
|
262
266
|
message: message))
|
|
263
267
|
end
|
|
264
268
|
|
|
@@ -270,37 +274,44 @@ module Mistri
|
|
|
270
274
|
|
|
271
275
|
# Answer or park the assistant's tool calls. Ungated calls execute (only
|
|
272
276
|
# on a genuine tool_use turn with no abort; otherwise they pair with
|
|
273
|
-
# interrupted results). Gated calls park as approval requests
|
|
274
|
-
#
|
|
277
|
+
# interrupted results). Gated calls park as approval requests. Nothing
|
|
278
|
+
# is left dangling either way. Returns the parked calls and whether an
|
|
279
|
+
# executed tool ends the turn; a parked call outranks an executed
|
|
280
|
+
# ends_turn, because a suspension is the stronger stop and the model
|
|
281
|
+
# regains the floor when the run resumes.
|
|
275
282
|
def run_tools(assistant, signal, &emit)
|
|
276
283
|
calls = assistant.tool_calls
|
|
277
284
|
unless assistant.stop_reason == StopReason::TOOL_USE && !signal&.aborted?
|
|
278
285
|
calls.each { |call| answer(call, ToolExecutor::INTERRUPTED, &emit) }
|
|
279
|
-
return []
|
|
286
|
+
return [[], false]
|
|
280
287
|
end
|
|
281
288
|
|
|
282
289
|
parked, free = screen(calls, signal, &emit).partition { |call| gated?(call) }
|
|
283
|
-
execute(free, signal, &emit)
|
|
290
|
+
executed = execute(free, signal, &emit)
|
|
284
291
|
parked.each do |call|
|
|
285
292
|
@session.append("approval_request", "call" => call.to_h)
|
|
286
293
|
emit&.call(Event.new(type: :approval_needed, tool_call: call))
|
|
287
294
|
end
|
|
288
|
-
parked
|
|
295
|
+
[parked, executed.any? { |call| ends_turn?(call) }]
|
|
289
296
|
end
|
|
290
297
|
|
|
298
|
+
# Returns the calls that actually executed (denied ones only answer).
|
|
291
299
|
def settle(open, signal, &emit)
|
|
292
300
|
approved, denied = open.partition { |approval| approval[:decision]["approved"] }
|
|
293
301
|
cleared = screen(approved.map { |approval| approval[:call] }, signal, &emit)
|
|
294
|
-
execute(cleared, signal, &emit)
|
|
302
|
+
executed = execute(cleared, signal, &emit)
|
|
295
303
|
denied.each do |approval|
|
|
296
304
|
note = approval[:decision]["note"]
|
|
297
305
|
text = "The user denied this tool call#{note ? ": #{note}" : "."}"
|
|
298
306
|
answer(approval[:call], text, &emit)
|
|
299
307
|
end
|
|
308
|
+
executed
|
|
300
309
|
end
|
|
301
310
|
|
|
311
|
+
# Runs the calls and answers each; returns the calls that executed
|
|
312
|
+
# (blocked and parked calls never reach here).
|
|
302
313
|
def execute(calls, signal, &emit)
|
|
303
|
-
return if calls.empty?
|
|
314
|
+
return [] if calls.empty?
|
|
304
315
|
|
|
305
316
|
results = ToolExecutor.call(calls, @tools_by_name, signal: signal,
|
|
306
317
|
max_concurrency: @max_concurrency,
|
|
@@ -311,6 +322,7 @@ module Mistri
|
|
|
311
322
|
result = rewrite(call, result, context) if @after_tool
|
|
312
323
|
answer(call, result, duration: seconds, &emit)
|
|
313
324
|
end
|
|
325
|
+
calls
|
|
314
326
|
end
|
|
315
327
|
|
|
316
328
|
# A blocked call answers in band, so the model reads the reason and
|
|
@@ -359,10 +371,22 @@ module Mistri
|
|
|
359
371
|
tool ? tool.needs_approval?(call.arguments) : false
|
|
360
372
|
end
|
|
361
373
|
|
|
362
|
-
def
|
|
374
|
+
def ends_turn?(call)
|
|
375
|
+
tool = @tools_by_name[call.name]
|
|
376
|
+
tool ? tool.ends_turn? : false
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# A run stopped during its tool phase ends with a clean assistant
|
|
380
|
+
# message, so the message's stop reason alone would read :completed;
|
|
381
|
+
# the signal is what knows the user stopped it. handed_off marks a run
|
|
382
|
+
# an ends_turn tool ended, and only a genuinely completed one: a run
|
|
383
|
+
# aborted during that same tool phase handed nothing to anyone.
|
|
384
|
+
def finished(message, usage, signal = nil, handed_off: false)
|
|
363
385
|
status = { StopReason::ABORTED => :aborted, StopReason::BUDGET => :budget,
|
|
364
386
|
StopReason::ERROR => :error }.fetch(message.stop_reason, :completed)
|
|
365
|
-
|
|
387
|
+
status = :aborted if status == :completed && signal&.aborted?
|
|
388
|
+
Result.new(message: message, status: status, usage: usage,
|
|
389
|
+
handed_off: handed_off && status == :completed)
|
|
366
390
|
end
|
|
367
391
|
|
|
368
392
|
def suspended(message, parked, usage)
|
|
@@ -379,35 +403,6 @@ module Mistri
|
|
|
379
403
|
Result.new(message: message, status: :budget, usage: usage)
|
|
380
404
|
end
|
|
381
405
|
|
|
382
|
-
# Distinguishable from a parsed nil: JSON "null" is a valid value.
|
|
383
|
-
PARSE_FAILED = Object.new.freeze
|
|
384
|
-
private_constant :PARSE_FAILED
|
|
385
|
-
|
|
386
|
-
def task_input(input, schema)
|
|
387
|
-
"#{input}\n\nAnswer with ONLY a JSON value matching this schema:\n" \
|
|
388
|
-
"#{JSON.generate(Schema.strict(schema))}"
|
|
389
|
-
end
|
|
390
|
-
|
|
391
|
-
def parse_output(text)
|
|
392
|
-
body = text.to_s.strip
|
|
393
|
-
body = body[/\A```(?:json)?\s*(.*?)```\z/m, 1] || body
|
|
394
|
-
JSON.parse(body)
|
|
395
|
-
rescue JSON::ParserError
|
|
396
|
-
PARSE_FAILED
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
def task_errors(value, schema)
|
|
400
|
-
return ["the answer is not valid JSON"] if value.equal?(PARSE_FAILED)
|
|
401
|
-
|
|
402
|
-
Schema.violations(value, schema)
|
|
403
|
-
end
|
|
404
|
-
|
|
405
|
-
def fix_prompt(errors)
|
|
406
|
-
lines = errors.map { |error| "- #{error}" }.join("\n")
|
|
407
|
-
"Your answer did not satisfy the required output schema. Problems:\n" \
|
|
408
|
-
"#{lines}\nReply with ONLY the corrected JSON."
|
|
409
|
-
end
|
|
410
|
-
|
|
411
406
|
def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
412
407
|
end
|
|
413
408
|
end
|
data/lib/mistri/child.rb
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# A sub-agent as its parent (or the host UI) sees it: a window onto the
|
|
5
|
+
# child's own session. Everything here is derived from the store, so it
|
|
6
|
+
# reads the same from any process, while the child runs and forever after.
|
|
7
|
+
#
|
|
8
|
+
# session.children # => [#<Mistri::Child Magpie done>, ...]
|
|
9
|
+
# child.status # => :running, :done, :stopped, :failed
|
|
10
|
+
# child.report # the terminal entry's report, once finished
|
|
11
|
+
# child.transcript(tail: 20) # recent entries, image bytes stripped
|
|
12
|
+
# child.say("Also check their pricing page")
|
|
13
|
+
#
|
|
14
|
+
# Status is a walk over the child's own entries: a terminal entry wins; a
|
|
15
|
+
# started child is :running while its lease holds and :interrupted once
|
|
16
|
+
# it lapses (with a lock adapter; without one there is no liveness signal
|
|
17
|
+
# and no-terminal stays :running); a dispatched-but-never-started child is
|
|
18
|
+
# :queued, honestly, because the host's queue owns that gap.
|
|
19
|
+
class Child
|
|
20
|
+
TERMINAL = "subagent_result"
|
|
21
|
+
DISPATCHED = "subagent_dispatched"
|
|
22
|
+
STARTED = "subagent_started"
|
|
23
|
+
# The states a worker can still be caught in: steerable, stoppable,
|
|
24
|
+
# worth waiting on.
|
|
25
|
+
LIVE = %i[running queued].freeze
|
|
26
|
+
|
|
27
|
+
attr_reader :name, :session_id
|
|
28
|
+
|
|
29
|
+
def self.lease_key(session_id) = "child:#{session_id}"
|
|
30
|
+
|
|
31
|
+
def self.stop_key(session_id) = "child-stop:#{session_id}"
|
|
32
|
+
|
|
33
|
+
def initialize(name:, session_id:, store:)
|
|
34
|
+
@name = name
|
|
35
|
+
@session_id = session_id
|
|
36
|
+
@store = store
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def status
|
|
40
|
+
log = session.entries
|
|
41
|
+
terminal = log.reverse_each.find { |entry| entry["type"] == TERMINAL }
|
|
42
|
+
return terminal["status"].to_sym if terminal
|
|
43
|
+
if log.any? { |entry| entry["type"] == DISPATCHED } &&
|
|
44
|
+
log.none? { |entry| entry["type"] == STARTED }
|
|
45
|
+
return :queued
|
|
46
|
+
end
|
|
47
|
+
return :interrupted if Mistri.locks && !Mistri.locks.held?(self.class.lease_key(@session_id))
|
|
48
|
+
|
|
49
|
+
:running
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A terminal entry exists: the child ended as done, stopped, or failed
|
|
53
|
+
# and will never run again. The question a queue retry asks; its
|
|
54
|
+
# inverse (started but no terminal) is what makes a crashed child
|
|
55
|
+
# re-runnable.
|
|
56
|
+
def finished?
|
|
57
|
+
!terminal_entry.nil?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def report
|
|
61
|
+
terminal_entry&.fetch("report", nil)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The terminal entry's error string, once failed.
|
|
65
|
+
def error
|
|
66
|
+
terminal_entry&.fetch("error", nil)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Recent entries, oldest first, with inline image bytes replaced by a
|
|
70
|
+
# marker: transcripts are for reading and re-sending, not for hauling
|
|
71
|
+
# screenshots back into a context window.
|
|
72
|
+
def transcript(tail: 20)
|
|
73
|
+
entries = session.entries
|
|
74
|
+
entries = entries.last(tail) if tail
|
|
75
|
+
entries.map { |entry| self.class.strip_images(entry) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Queue a message the child folds at its next turn boundary. Delivery is
|
|
79
|
+
# honest, not instant: a child mid-step sees it after that step, and a
|
|
80
|
+
# child that finishes first never sees it.
|
|
81
|
+
def say(text)
|
|
82
|
+
session.steer(text)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Ask a live child to stop, from any process. A running child's runner
|
|
86
|
+
# sees the flag within a tick and trips its own signal; a queued child
|
|
87
|
+
# is cancelled outright with a stopped terminal, which the runner
|
|
88
|
+
# honors by never starting it. Stop is cross-process by nature, so it
|
|
89
|
+
# needs a lock adapter; without one this returns false. An action that
|
|
90
|
+
# reports acceptance, not a predicate.
|
|
91
|
+
def stop # rubocop:disable Naming/PredicateMethod
|
|
92
|
+
return false unless Mistri.locks
|
|
93
|
+
|
|
94
|
+
session.append(TERMINAL, "status" => "stopped") if status == :queued
|
|
95
|
+
Mistri.locks.set_flag(self.class.stop_key(@session_id))
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def to_h
|
|
100
|
+
{ "name" => name, "session_id" => session_id, "status" => status.to_s }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def inspect
|
|
104
|
+
"#<Mistri::Child #{name} #{status}>"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.strip_images(value)
|
|
108
|
+
case value
|
|
109
|
+
when Hash
|
|
110
|
+
if value["data"] && value["mime_type"]
|
|
111
|
+
value.except("data").merge("omitted" => true)
|
|
112
|
+
else
|
|
113
|
+
value.transform_values { |nested| strip_images(nested) }
|
|
114
|
+
end
|
|
115
|
+
when Array then value.map { |nested| strip_images(nested) }
|
|
116
|
+
else value
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def session
|
|
123
|
+
@session ||= Session.new(store: @store, id: @session_id)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def terminal_entry
|
|
127
|
+
session.entries.reverse_each.find { |entry| entry["type"] == TERMINAL }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
data/lib/mistri/compactor.rb
CHANGED
|
@@ -5,7 +5,7 @@ require "json"
|
|
|
5
5
|
module Mistri
|
|
6
6
|
# Compacts a session in place: everything before a cut point is summarized
|
|
7
7
|
# by the provider, and a compaction entry redirects replay to the summary
|
|
8
|
-
# plus the kept tail. Append-only
|
|
8
|
+
# plus the kept tail. Append-only: the full history stays in the store for
|
|
9
9
|
# transcript UIs; only what the model sees shrinks. Callable from any
|
|
10
10
|
# process (a UI button, a job), with or without a running agent.
|
|
11
11
|
#
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# The management console: the tools an agent gets for managing the workers
|
|
5
|
+
# it spawns. Every tool is a thin wrapper over Session#children and the
|
|
6
|
+
# Child facade, the same functions a host UI calls, so nothing the agent
|
|
7
|
+
# can do is hidden from the user and nothing the user does confuses the
|
|
8
|
+
# agent. Tools are stateless: each call reads the calling session's
|
|
9
|
+
# children at that moment.
|
|
10
|
+
#
|
|
11
|
+
# agent = Mistri::Agent.new(provider:, tools: [spawn, *Mistri::Console.tools])
|
|
12
|
+
#
|
|
13
|
+
# Workers are addressed by name or session id, uniformly, in every tool.
|
|
14
|
+
# When two workers share a name, the most recently spawned one answers;
|
|
15
|
+
# ids stay unambiguous.
|
|
16
|
+
module Console
|
|
17
|
+
READ_TIMEOUT = 300
|
|
18
|
+
POLL = 0.5
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def tools(read_timeout: READ_TIMEOUT, poll: POLL)
|
|
23
|
+
[list_agents, read_agent(timeout: read_timeout, poll: poll), steer_agent, stop_agent]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def list_agents
|
|
27
|
+
Tool.define(
|
|
28
|
+
"list_agents",
|
|
29
|
+
"See your workers: who is running, who finished, who was stopped. " \
|
|
30
|
+
"Check here before spawning a duplicate."
|
|
31
|
+
) do |_args, context|
|
|
32
|
+
children = context.session.children
|
|
33
|
+
next "You have no workers." if children.empty?
|
|
34
|
+
|
|
35
|
+
children.map do |child|
|
|
36
|
+
"#{child.name} (#{child.session_id[0, 8]}): #{child.status}"
|
|
37
|
+
end.join("\n")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def read_agent(timeout: READ_TIMEOUT, poll: POLL)
|
|
42
|
+
Tool.define(
|
|
43
|
+
"read_agent",
|
|
44
|
+
"Read what a worker has done so far without interrupting it, or pass " \
|
|
45
|
+
"wait to block until it finishes and get its report.",
|
|
46
|
+
schema: lambda {
|
|
47
|
+
string :agent, "The worker's name or session id", required: true
|
|
48
|
+
integer :tail, "How many recent entries to read (default 20)"
|
|
49
|
+
boolean :wait, "Block until the worker finishes, then return its report"
|
|
50
|
+
}
|
|
51
|
+
) do |args, context|
|
|
52
|
+
child = Console.find(context.session, args["agent"])
|
|
53
|
+
next Console.unknown(context.session, args["agent"]) unless child
|
|
54
|
+
|
|
55
|
+
if args["wait"]
|
|
56
|
+
Console.await(child, timeout, poll, context.signal)
|
|
57
|
+
else
|
|
58
|
+
Console.render(child, args.fetch("tail", 20).to_i.clamp(1, 200))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def steer_agent
|
|
64
|
+
Tool.define(
|
|
65
|
+
"steer_agent",
|
|
66
|
+
"Redirect or correct a running worker. It sees your message at its " \
|
|
67
|
+
"next step; it may finish first. For a full restart, stop it and " \
|
|
68
|
+
"spawn again with better instructions.",
|
|
69
|
+
schema: lambda {
|
|
70
|
+
string :agent, "The worker's name or session id", required: true
|
|
71
|
+
string :message, "What the worker should know or do differently", required: true
|
|
72
|
+
}
|
|
73
|
+
) do |args, context|
|
|
74
|
+
child = Console.find(context.session, args["agent"])
|
|
75
|
+
next Console.unknown(context.session, args["agent"]) unless child
|
|
76
|
+
|
|
77
|
+
status = child.status
|
|
78
|
+
if Child::LIVE.include?(status)
|
|
79
|
+
child.say(args.fetch("message"))
|
|
80
|
+
"Queued. #{child.name} sees it at its next step; it may finish first."
|
|
81
|
+
else
|
|
82
|
+
"#{child.name} is #{status}, so there is nothing to steer. " \
|
|
83
|
+
"Spawn a new worker for follow-up work."
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def stop_agent
|
|
89
|
+
Tool.define(
|
|
90
|
+
"stop_agent",
|
|
91
|
+
"Stop one worker. Its partial work is kept and its transcript stays " \
|
|
92
|
+
"readable. Other workers and your own run continue.",
|
|
93
|
+
schema: lambda {
|
|
94
|
+
string :agent, "The worker's name or session id", required: true
|
|
95
|
+
}
|
|
96
|
+
) do |args, context|
|
|
97
|
+
child = Console.find(context.session, args["agent"])
|
|
98
|
+
next Console.unknown(context.session, args["agent"]) unless child
|
|
99
|
+
|
|
100
|
+
status = child.status
|
|
101
|
+
if !Child::LIVE.include?(status)
|
|
102
|
+
"#{child.name} is already #{status}."
|
|
103
|
+
elsif !child.stop
|
|
104
|
+
"Stopping needs a lock adapter (Mistri.locks) and none is configured."
|
|
105
|
+
elsif status == :queued
|
|
106
|
+
"Cancelled. #{child.name} had not started and never will."
|
|
107
|
+
else
|
|
108
|
+
"Stop requested. #{child.name} halts within a second or two; " \
|
|
109
|
+
"its partial work stays readable through read_agent."
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Ids are advertised as unambiguous, so they resolve first: exact, then
|
|
115
|
+
# an 8+ character prefix (what list_agents displays). Names come last,
|
|
116
|
+
# latest spawn winning on duplicates, so a model-chosen name can never
|
|
117
|
+
# shadow another worker's id.
|
|
118
|
+
def find(session, ref)
|
|
119
|
+
children = session.children
|
|
120
|
+
children.find { |child| child.session_id == ref } ||
|
|
121
|
+
(ref.to_s.length >= 8 &&
|
|
122
|
+
children.find { |child| child.session_id.start_with?(ref) }) ||
|
|
123
|
+
children.reverse_each.find { |child| child.name == ref }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def unknown(session, ref)
|
|
127
|
+
names = session.children.map(&:name).uniq
|
|
128
|
+
known = names.empty? ? "you have no workers" : "your workers: #{names.join(", ")}"
|
|
129
|
+
"No worker matches #{ref.inspect}; #{known}."
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The wait is cooperative like everything else: the run's abort ends it
|
|
133
|
+
# promptly, never held hostage to the timeout.
|
|
134
|
+
def await(child, timeout, poll, signal = nil)
|
|
135
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
136
|
+
while Child::LIVE.include?(status = child.status)
|
|
137
|
+
return "The wait was stopped; #{child.name} is still #{status}." if signal&.aborted?
|
|
138
|
+
|
|
139
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
140
|
+
return "#{child.name} is still #{status} after #{timeout.round}s. " \
|
|
141
|
+
"Read it without wait to see progress, or stop it."
|
|
142
|
+
end
|
|
143
|
+
sleep poll
|
|
144
|
+
end
|
|
145
|
+
report = child.report
|
|
146
|
+
"#{child.name} #{child.status}." + (report ? "\n#{report}" : "")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A compact, readable transcript for a model: one line per entry, text
|
|
150
|
+
# extracted, long values truncated. The gem never summarizes; the
|
|
151
|
+
# caller can.
|
|
152
|
+
def render(child, tail)
|
|
153
|
+
entries = child.transcript(tail: tail)
|
|
154
|
+
return "#{child.name} (#{child.status}): no entries yet." if entries.empty?
|
|
155
|
+
|
|
156
|
+
lines = entries.map { |entry| Console.line(entry) }.compact
|
|
157
|
+
"#{child.name} (#{child.status}), last #{lines.length} entries:\n#{lines.join("\n")}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def line(entry)
|
|
161
|
+
case entry["type"]
|
|
162
|
+
when "message" then message_line(entry["message"] || {})
|
|
163
|
+
when Child::TERMINAL
|
|
164
|
+
["#{entry["status"]}:", entry["report"] || entry["error"]].compact.join(" ")[0, 300]
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Tool calls ride the message's content as typed blocks, never as a
|
|
169
|
+
# separate key; render both channels from the blocks.
|
|
170
|
+
def message_line(message)
|
|
171
|
+
blocks = message["content"].is_a?(Array) ? message["content"] : []
|
|
172
|
+
calls = blocks.filter_map do |block|
|
|
173
|
+
next unless block.is_a?(Hash) && block["type"] == "tool_call"
|
|
174
|
+
|
|
175
|
+
"#{block["name"]}(#{JSON.generate(block["arguments"] || {})[0, 80]})"
|
|
176
|
+
end
|
|
177
|
+
text = Console.text_of(message["content"])
|
|
178
|
+
parts = [text[0, 240], *calls].reject { |part| part.to_s.empty? }
|
|
179
|
+
"#{message["role"]}: #{parts.join(" | ")}" unless parts.empty?
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def text_of(content)
|
|
183
|
+
return content.to_s unless content.is_a?(Array)
|
|
184
|
+
|
|
185
|
+
content.filter_map { |block| block["text"] if block.is_a?(Hash) }.join(" ")
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|