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.
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. Session#steer queues a user message from any process while
17
- # the loop runs; it folds into the transcript at the next turn boundary.
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, "session is awaiting approval decisions; call resume"
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, "run needs input text or images"
71
+ raise ArgumentError,
72
+ "run needs input text or images"
73
73
  end
74
74
 
75
- fold_steers # steers queued while idle arrived first; keep that order
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(task_input(input, schema), images: images, signal: signal,
108
- output_schema: schema, &emit)
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 = parse_output(result.text)
115
- errors = task_errors(value, schema)
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
- fold_steers
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 cleanly extends the run one
167
- # more turn so it gets answered. Aborts, errors, and length stops always
168
- # end the run; the steer stays pending for the next one.
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.pending_steers.empty?
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 into the transcript in arrival order. The
195
- # folded message entry carries the steer id, which is what marks the steer
196
- # consumed: one append is both the fold and the marker, so a crash between
197
- # steers never double-delivers.
198
- def fold_steers
199
- @session.pending_steers.each do |steer|
200
- @session.append("message", "message" => steer["message"], "steer_id" => steer["id"])
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 is recorded as a retry entry, never as a message, so retries
212
- # stay invisible to the model. Only the final outcome persists.
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, &emit)
237
+ output_schema: output_schema, &gate)
222
238
  attempt += 1
223
- error = turn_error(message)
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, reason: StopReason::ERROR,
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 and are
274
- # returned, so the loop can suspend. Nothing is left dangling either way.
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 finished(message, usage)
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
- Result.new(message: message, status: status, usage: usage)
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
@@ -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
@@ -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 the full history stays in the store for
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