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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 820753fb8dd64f485da060568e6dbc8a7af3fa24d63a2cc7dc957b5dbfc9f1d6
4
- data.tar.gz: c3e7432802801d19979c3d2906f700fbe3170a1d4950d22f0da88e01b276b905
3
+ metadata.gz: d46cab51a8a899ddd2e3cd0284a7e9eb4185d1362af158303adb97c2a404a657
4
+ data.tar.gz: 548bf1b8cdc9d22f304674eb5b876b624c16f6b3cc31839daba83909f78641a5
5
5
  SHA512:
6
- metadata.gz: 78959db170082e7264a3657dec0ff1e21ebf496a2e2603fe98ca6a7b263f68f980456df3f2881d798f2a8e7a296902700b2dc4e155a9ef43f54a32df5ca75eb7
7
- data.tar.gz: 565be0a187d2651d92d43e59351090e0d6623dff13d70bbd1747e5c35f5167f162a4182c111e265734eadbc9aa465be003d5704da61495af281b321036eb0f26
6
+ metadata.gz: 69b2758b3b137714bb2bffc170ff6520f1d38851230ba19acabbb50042d28234ce934d74555fd328d12d1e8002afe6d96bab8dd21a06567411252e4aeabac973
7
+ data.tar.gz: 972354e7a95bebfa4f5918efa84f13cbc6e45e9e8dabdf49a60a4d210ecf7c11425866b2ad4d8b648916477568aa2ac69915c98d241311e3778c38667d570558
data/CHANGELOG.md CHANGED
@@ -3,7 +3,135 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
- ## [Unreleased]
6
+ ## [0.5.0] - 2026-07-08
7
+
8
+ - The children registry: Session#children lists every sub-agent a session
9
+ has spawned as a Mistri::Child, a window onto the child's own session
10
+ with name, status, report, transcript(tail:) with image bytes stripped,
11
+ and say(text) to steer it. All of it derives from the store, so it reads
12
+ the same from any process, while the child runs and forever after.
13
+ - Completion is a contract: every child ends by writing a terminal entry
14
+ (done with its report, stopped, or failed with the error), including
15
+ when the child's run raises, so a crashed child never reads as running.
16
+
17
+ - Spawn policy is an object: Mistri::Spawner carries the pool, types,
18
+ models, headcount, and dispatcher; SubAgent.spawner and SubAgent.pack
19
+ stay the front doors.
20
+ - Typed workers: the spawner takes types:, a host registry of Definitions
21
+ by name. A typed child takes its system prompt, tools, and model from
22
+ the definition; instructions appends; explicit tool and model args
23
+ override within the pool and allowlist. "general-purpose" stays the
24
+ built-in composable default. Types fail at construction, never
25
+ mid-spawn: a definition with unfilled placeholders or tools the pool
26
+ lacks is a boot-time ConfigurationError.
27
+ - max_children (default 4) caps live workers per session; a spawn past
28
+ the cap answers in band and freed slots reopen.
29
+ - SubAgent.pack returns the spawn tool plus the management console in one
30
+ call, the whole kit for a worker-running agent.
31
+
32
+ - Background mode: spawn_agent takes mode: "background" when the spawner
33
+ has a dispatcher, returning a truthful receipt immediately (what the
34
+ child's status says after dispatch, not what the mode promised) while
35
+ the parent keeps working; the report arrives on its own (above). The
36
+ dispatcher is a seam: Dispatchers::Inline (default degrade, synchronous
37
+ but honest) and Dispatchers::Thread ship in the gem, and a queue host
38
+ plugs one lambda whose job reconstructs tools from the serializable
39
+ spec and calls SubAgent.run_dispatched.
40
+ - Lifecycle is entries: subagent_dispatched and subagent_started join the
41
+ terminal, so status walks the store alone: queued, running, interrupted,
42
+ done, stopped, failed. A job that dies before starting reads :queued,
43
+ honestly.
44
+ - A background child runs on its own signal: the parent's turn is over, so
45
+ only stop_agent and the stop flag end it early. workspace: "parent"
46
+ requires inline mode, enforced in band.
47
+
48
+ - Report delivery: a background child's terminal outcome reports back to
49
+ its parent, exactly once. The report queues in the parent's inbox as a
50
+ typed subagent_report entry and folds at the next turn boundary the way
51
+ a steer does (the model sees `[Magpie finished] <report>`; failures
52
+ carry the error, stops say so), and a report landing as a run finishes
53
+ cleanly extends it one turn so the parent reacts. A :subagent_report
54
+ event (agent, session_id, status, content) closes the child's lane in
55
+ whatever UI watched the spawn. Session#pending_inbox is the
56
+ steers-and-reports view (hosts that wake idle sessions on steers should
57
+ watch it instead); Session#deliver_report is the idempotent primitive
58
+ underneath.
59
+ - Dispatched runs are fenced by the child's lease, taken before the
60
+ run-or-not decision: a queue that redelivers a live job leaves the
61
+ owner alone, a retry of a finished or cancelled child is a clean no-op,
62
+ and a retry of a child a crashed process left mid-run runs it again
63
+ (previously the guard refused it, wedging the child as interrupted
64
+ forever). Child#finished? and Child#error are new readers.
65
+
66
+ - The management console: Mistri::Console.tools returns list_agents,
67
+ read_agent (tail: to choose how much transcript, wait: to block for the
68
+ report with an in-band timeout), steer_agent, and stop_agent. Every tool
69
+ is a thin wrapper over Session#children and the Child facade, the same
70
+ functions a host UI calls, so agent and user control stay structurally
71
+ equal. Workers answer to name or session id uniformly in every tool;
72
+ duplicate names resolve to the latest spawn, ids stay unambiguous, and
73
+ every state answers honestly in band (already done, nothing to steer,
74
+ stopping needs a lock adapter).
75
+
76
+ - Stop one child, keep the run: every sub-agent now runs on its own signal
77
+ derived from the parent's (AbortSignal#derive), so the parent's abort
78
+ still cascades down while Child#stop ends a single worker and the parent
79
+ reasons on with "[the X sub-agent was stopped]". Cross-process stops ride
80
+ the lock adapter's flags; the lease thread turns them into the child's
81
+ cooperative abort within a tick.
82
+ - A run stopped during its tool phase now reports :aborted. The final
83
+ assistant message is clean in that case, so the message's stop reason
84
+ read :completed and a user-stopped run could claim success; the signal
85
+ is consulted alongside the message.
86
+
87
+ - The lock adapter: Mistri.locks takes an adapter for cross-process leases
88
+ and flags (Locks::Memory built in; Locks::RailsCache as an opt-in require,
89
+ the Stores::ActiveRecord pattern). Locks.hold keeps a lease alive on a
90
+ heartbeat from a background thread and releases it cleanly, join and all,
91
+ so a mid-renewal tick can never re-stamp a released lease.
92
+ - Children gain liveness: every child run holds a lease, and with an
93
+ adapter configured a child that died without writing its terminal entry
94
+ reads :interrupted instead of :running forever. Without an adapter
95
+ nothing changes.
96
+
97
+ - Session#transcript reads the whole conversation back from the store:
98
+ entries with image bytes stripped, and with include_children every
99
+ sub-agent's log spliced in after its link entry, tagged with an
100
+ "origin" key shaped exactly like the live stream's event origins
101
+ (nesting joined with ">"). A UI that rebuilds from the transcript shows
102
+ the lanes it showed live, running children's progress-so-far included;
103
+ hosts stop hand-walking link entries.
104
+
105
+ - Tool.define takes ends_turn: true for a tool that is the last word of
106
+ its turn: once it executes, the loop ends the run instead of prompting
107
+ the model again, so an ask_user tool hands the floor to a human
108
+ structurally instead of through prompt discipline. The whole batch it
109
+ arrived in still executes and is answered; a blocked or denied call
110
+ never executed, so the model keeps the floor; a parked approval outranks
111
+ it (the run suspends, and an approved ends_turn call ends the resumed
112
+ run). A pending steer stays queued for the next run. The Result says it
113
+ happened (Result#handed_off?), so hosts route on the handoff instead of
114
+ sniffing messages, and task mode returns the handoff as-is rather than
115
+ re-prompting for JSON while a human holds the floor.
116
+
117
+ - Store appends tolerate concurrent writers. Sessions have more than one
118
+ appender by design (the loop, a steer from a web process, a worker's
119
+ report from a job), so the ActiveRecord store's unique index is now
120
+ concurrency control rather than a tripwire: a writer that loses the
121
+ position race retries at the next slot, bounded, then raises loudly.
122
+ The JSONL store writes each line in a single call, so concurrent
123
+ appenders interleave whole lines, never fragments.
124
+
125
+ - The Fake provider streams tool-call arguments in chunks, and each
126
+ delta's partial carries the in-progress call with arguments parsed so
127
+ far, the same shape a real assembler builds. A consumer that renders
128
+ tool input as it arrives (a page preview, a code block) is now testable
129
+ headless.
130
+ - Each run of a named specialist can carry its own name: the delegate
131
+ tool takes an optional name argument, so two parallel researchers read
132
+ as Corgi and Beagle in lanes, lists, and links instead of "researcher"
133
+ twice. SubAgent.sanitize_label is the one shared sanitizer behind
134
+ specialist runs and spawner labels.
7
135
 
8
136
  ## [0.4.1] - 2026-07-06
9
137
 
@@ -144,7 +272,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
144
272
  keep their old arity. Events gained an origin field.
145
273
 
146
274
  - Task mode: Agent#task(input, schema:) runs an exchange that must end in
147
- JSON matching the schema tools run as usual, providers constrain the
275
+ JSON matching the schema: tools run as usual, providers constrain the
148
276
  final answer natively where supported (Anthropic output_config, OpenAI
149
277
  text.format strict, Gemini responseJsonSchema when no tools), and the
150
278
  answer validates client-side everywhere. A violation goes back to the
@@ -164,8 +292,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
164
292
  - Rails integration: `rails generate mistri:install YourModel` creates a
165
293
  host-named entry model and its migration for Stores::ActiveRecord
166
294
  (MEDIUMTEXT payload on MySQL-family adapters). Streaming sinks under
167
- Mistri::Sinks ActionCable (lazy server, injectable), SSE (outbound
168
- frames to any IO), and Coalesced (merges delta bursts to UI speed) all
295
+ Mistri::Sinks: ActionCable (lazy server, injectable), SSE (outbound
296
+ frames to any IO), and Coalesced (merges delta bursts to UI speed), all
169
297
  pure Ruby, usable as `agent.run(input, &sink)`. No Railtie: generators
170
298
  auto-discover and everything else duck-types.
171
299
 
data/README.md CHANGED
@@ -104,7 +104,7 @@ end
104
104
 
105
105
  Handlers and hooks can take the run's context as a second argument, and
106
106
  `context.app` carries whatever object you pass as `Mistri.agent(context:)`
107
- the acting user, a tenant, a request so tools stay authorization-aware
107
+ (the acting user, a tenant, a request), so tools stay authorization-aware
108
108
  without closure gymnastics:
109
109
 
110
110
  ```ruby
@@ -116,6 +116,21 @@ Mistri::Tool.define("book_hotel", "Books the chosen hotel.") do |args, context|
116
116
  end
117
117
  ```
118
118
 
119
+ A tool can also be the last word of its turn. `ends_turn: true` makes the
120
+ loop end the run once the tool executes, instead of prompting the model
121
+ again: an `ask_user` tool hands the floor to a human structurally, no
122
+ "remember to stop after asking" prompt required. The result says it
123
+ happened (`result.handed_off?`), and the answer arrives as the next run's
124
+ input:
125
+
126
+ ```ruby
127
+ ask_user = Mistri::Tool.define("ask_user", "Ask the human and wait.",
128
+ ends_turn: true,
129
+ schema: -> { string :question, "The question", required: true }) do |args|
130
+ "Question presented to the user."
131
+ end
132
+ ```
133
+
119
134
  ## Human approval
120
135
 
121
136
  Mark a tool `needs_approval: true` (or a predicate on its arguments) and the
@@ -152,6 +167,10 @@ finishes cleanly extends the run so it gets answered.
152
167
  Mistri::Session.new(store:, id: session_id).steer("Make the headline blue instead.")
153
168
  ```
154
169
 
170
+ Background workers' reports arrive through the same inbox (see Sub-agents).
171
+ A host that wakes an idle session when a steer lands should watch
172
+ `session.pending_inbox`, which holds both, in arrival order.
173
+
155
174
  ## Sessions
156
175
 
157
176
  A session is the durable record of a run: an append-only entry log over a
@@ -215,6 +234,10 @@ result = agent.task("Extract the pricing tiers from this page.", schema: schema)
215
234
  result.output # => { "tiers" => [...] }, parsed and validated
216
235
  ```
217
236
 
237
+ A run that suspends for approval, or that an `ends_turn` tool ended,
238
+ returns as-is: validation applies to answers, not handoffs. Route on
239
+ `result.handed_off?` and ask again once the human's answer arrives.
240
+
218
241
  ## Skills
219
242
 
220
243
  Expert playbooks with progressive disclosure: each skill costs one line in
@@ -248,7 +271,9 @@ agent = Mistri.agent(definition.model,
248
271
  Delegate to a child agent with a clean context: exploration fills the
249
272
  child's window, and only the final answer returns. Children run on their own
250
273
  sessions in your store, linked in the parent transcript; their events stream
251
- into the parent tagged with an `origin`.
274
+ into the parent tagged with an `origin`. Each run of a specialist can carry
275
+ its own name, so two parallel researchers read as Corgi and Beagle in your
276
+ UI instead of "researcher" twice.
252
277
 
253
278
  ```ruby
254
279
  researcher = Mistri::SubAgent.new(
@@ -260,12 +285,65 @@ agent = Mistri.agent("claude-opus-4-8", tools: [researcher.tool])
260
285
  ```
261
286
 
262
287
  Or hand the model an open spawn tool and let it compose its own workers:
263
- a name for the event stream, instructions, a tool subset, and a
264
- host-allowlisted model per child. Several spawns in one turn fan out in
265
- parallel:
288
+ a name, instructions, a tool subset, and a host-allowlisted model per
289
+ child. Several spawns in one turn fan out in parallel. `pack` is the whole
290
+ kit: the spawn tool plus a management console (`list_agents`, `read_agent`,
291
+ `steer_agent`, `stop_agent`), with curated types and an optional
292
+ dispatcher:
266
293
 
267
294
  ```ruby
268
295
  spawn = Mistri::SubAgent.spawner(provider: provider, tools: [fetch_page, search])
296
+
297
+ tools = Mistri::SubAgent.pack(
298
+ provider: provider, tools: [fetch_page, search],
299
+ types: { "researcher" => Mistri::Definition.load("agents/researcher.md") },
300
+ models: ["claude-haiku-4-5-20251001"],
301
+ dispatcher: Mistri::Dispatchers::Thread.new, # or one lambda onto your queue
302
+ )
303
+ ```
304
+
305
+ A typed worker takes its prompt, tools, and model from the host's
306
+ definition; `general-purpose` stays open for the model to compose.
307
+ `max_children` (default 4) caps live workers, and every policy violation
308
+ answers the model in band.
309
+
310
+ Everything cross-process (stopping a worker from another process, the
311
+ `:interrupted` liveness read, and the lease fence below) rides a lock
312
+ adapter; configure one at boot. Without it, workers still run, but stops
313
+ need the parent's own signal, a crashed child reads `:running`, and
314
+ dispatched retries are unfenced:
315
+
316
+ ```ruby
317
+ Mistri.locks = Mistri::Locks::RailsCache.new # Locks::Memory for a single process
318
+ ```
319
+
320
+ With a dispatcher, `spawn_agent` takes `mode: "background"`: the model gets
321
+ a truthful receipt at once and keeps working while the child runs. The
322
+ console manages the roster with the same functions a host UI calls, so
323
+ agent and user control stay structurally equal: either can read a worker's
324
+ transcript, steer it mid-run, or stop it, from any process. When a worker
325
+ finishes, its report delivers itself: a typed entry queues in the parent's
326
+ inbox and folds at the next turn boundary as `[Corgi finished] <report>`
327
+ (failures carry the error), while a `:subagent_report` event settles the
328
+ worker's lane in whatever UI watched the spawn. In a queue host, the job
329
+ rebuilds tools from the serializable spec and calls
330
+ `SubAgent.run_dispatched`, which fences on the child's lease: a redelivered
331
+ job leaves the running owner alone, a retry of a finished child is a no-op,
332
+ and a retry of a crashed one runs it again.
333
+
334
+ Everything about a child derives from the store and reads the same from
335
+ any process, while it runs and forever after:
336
+
337
+ ```ruby
338
+ session.children # => [#<Mistri::Child Corgi running>, ...]
339
+ child.status # :queued, :running, :done, :stopped, :failed
340
+ child.report # the terminal entry's report, once finished
341
+ child.say("Check pricing too") # folds at the child's next turn boundary
342
+ child.stop # cooperative, cross-process, within a tick
343
+
344
+ session.transcript(include_children: true)
345
+ # the whole conversation, each child's log spliced at its link entry and
346
+ # origin-tagged like the live stream: a reloading UI rebuilds its lanes
269
347
  ```
270
348
 
271
349
  ## Editing documents
@@ -405,8 +483,11 @@ Mistri.agent("claude-opus-4-8", provider_options: { cache: false })
405
483
 
406
484
  ## Testing
407
485
 
408
- `rake test` is hermetic and fast. `rake integration` runs every feature end
409
- to end against real provider APIs, once per model in the matrix: an
486
+ `rake test` is hermetic and fast. The Fake provider streams like the real
487
+ ones, tool-call arguments included: each delta's partial carries the
488
+ in-progress call with arguments parsed so far, so a UI that renders tool
489
+ input as it arrives tests headless. `rake integration` runs every feature
490
+ end to end against real provider APIs, once per model in the matrix: an
410
491
  Anthropic, an OpenAI, and a Gemini model by default. Scenarios assert that
411
492
  coined codenames (a ghost of a word like `Wraithowyn` exists in no training
412
493
  data) flowed through tool results, summaries, and child agents: proof of
@@ -32,6 +32,16 @@ module Mistri
32
32
  nil
33
33
  end
34
34
 
35
+ # A signal that trips when this one does, never the reverse: abort the
36
+ # derived signal alone and this one runs on. Returns [signal, handle];
37
+ # remove the handle when the derived work ends, so a finished child
38
+ # never leaks a callback into a later abort.
39
+ def derive
40
+ child = AbortSignal.new
41
+ handle = on_abort { child.abort!(reason) }
42
+ [child, handle]
43
+ end
44
+
35
45
  # Register a callback for the moment of abort. Fires immediately when the
36
46
  # signal is already tripped. Returns a handle for #remove_callback.
37
47
  def on_abort(&callback)
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
@@ -68,7 +72,7 @@ module Mistri
68
72
  "run needs input text or images"
69
73
  end
70
74
 
71
- 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
72
76
  @session.append_message(Message.user_with_images(input, images))
73
77
  loop_turns(signal, output_schema, &emit)
74
78
  end
@@ -76,7 +80,8 @@ module Mistri
76
80
  # Continue a suspended run. Undecided approvals return immediately, still
77
81
  # suspended. Decided ones settle first: approved calls execute, denied
78
82
  # calls answer in band so the model knows and can react. Then the loop
79
- # 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.
80
85
  def resume(signal: nil, &emit)
81
86
  open = @session.open_approvals
82
87
  pending = open.select { |approval| approval[:decision].nil? }
@@ -86,7 +91,12 @@ module Mistri
86
91
  usage: Usage.zero)
87
92
  end
88
93
 
89
- 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
+
90
100
  loop_turns(signal, nil, &emit)
91
101
  end
92
102
 
@@ -98,21 +108,24 @@ module Mistri
98
108
  #
99
109
  # A run that suspends for approval returns as-is: validation applies to
100
110
  # completed runs only, so resume the session and re-ask if that happens
101
- # 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.
102
114
  def task(input, schema:, images: [], signal: nil, fixes: 1, &emit)
103
- result = run(task_input(input, schema), images: images, signal: signal,
104
- output_schema: schema, &emit)
115
+ result = run(TaskOutput.prompt(input, schema), images: images, signal: signal,
116
+ output_schema: schema, &emit)
105
117
  spent = result.usage
106
118
  fixes.downto(0) do |remaining|
107
119
  result = result.with(usage: spent)
108
120
  return result unless result.completed?
121
+ return result if result.handed_off?
109
122
 
110
- value = parse_output(result.text)
111
- errors = task_errors(value, schema)
123
+ value = TaskOutput.parse(result.text)
124
+ errors = TaskOutput.errors(value, schema)
112
125
  return result.with(output: value) if errors.empty?
113
126
  raise SchemaError, "task output failed validation: #{errors.join("; ")}" if remaining.zero?
114
127
 
115
- result = run(fix_prompt(errors), signal: signal, output_schema: schema, &emit)
128
+ result = run(TaskOutput.fix_prompt(errors), signal: signal, output_schema: schema, &emit)
116
129
  spent += result.usage
117
130
  end
118
131
  end
@@ -144,7 +157,7 @@ module Mistri
144
157
  reason = @budget.exceeded(turns: turns, usage: usage, elapsed: monotonic_now - started)
145
158
  return stop_for_budget(reason, usage, &emit) if reason
146
159
 
147
- fold_steers
160
+ fold_inbox
148
161
  compacted = auto_compact(&emit)
149
162
  usage += compacted[:usage] if compacted&.dig(:usage)
150
163
  last = run_turn(signal, output_schema, &emit)
@@ -153,20 +166,21 @@ module Mistri
153
166
 
154
167
  # Any tool call the turn made must be answered or parked, or the
155
168
  # transcript is unpairable and replay fails.
156
- parked = last.tool_calls? ? run_tools(last, signal, &emit) : []
169
+ parked, ended = last.tool_calls? ? run_tools(last, signal, &emit) : [[], false]
157
170
  return suspended(last, parked, usage) if parked.any?
158
- return finished(last, usage) if done?(last, signal)
171
+ return finished(last, usage, signal, handed_off: ended) if ended || done?(last, signal)
159
172
  end
160
173
  end
161
174
 
162
- # A steer that lands while the model finishes cleanly extends the run one
163
- # more turn so it gets answered. Aborts, errors, and length stops always
164
- # 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.
165
179
  def done?(last, signal)
166
180
  return false if last.stop_reason == StopReason::TOOL_USE && !signal&.aborted?
167
181
  return true if signal&.aborted? || last.stop_reason != StopReason::STOP
168
182
 
169
- @session.pending_steers.empty?
183
+ @session.pending_inbox.empty?
170
184
  end
171
185
 
172
186
  # Compact when the context has grown into the reserve. A failed
@@ -187,13 +201,15 @@ module Mistri
187
201
  @compaction&.window || Models.find(@provider.model)&.context_window
188
202
  end
189
203
 
190
- # Materialize queued steers into the transcript in arrival order. The
191
- # folded message entry carries the steer id, which is what marks the steer
192
- # consumed: one append is both the fold and the marker, so a crash between
193
- # steers never double-delivers.
194
- def fold_steers
195
- @session.pending_steers.each do |steer|
196
- @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"])
197
213
  end
198
214
  end
199
215
 
@@ -258,37 +274,44 @@ module Mistri
258
274
 
259
275
  # Answer or park the assistant's tool calls. Ungated calls execute (only
260
276
  # on a genuine tool_use turn with no abort; otherwise they pair with
261
- # interrupted results). Gated calls park as approval requests and are
262
- # 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.
263
282
  def run_tools(assistant, signal, &emit)
264
283
  calls = assistant.tool_calls
265
284
  unless assistant.stop_reason == StopReason::TOOL_USE && !signal&.aborted?
266
285
  calls.each { |call| answer(call, ToolExecutor::INTERRUPTED, &emit) }
267
- return []
286
+ return [[], false]
268
287
  end
269
288
 
270
289
  parked, free = screen(calls, signal, &emit).partition { |call| gated?(call) }
271
- execute(free, signal, &emit)
290
+ executed = execute(free, signal, &emit)
272
291
  parked.each do |call|
273
292
  @session.append("approval_request", "call" => call.to_h)
274
293
  emit&.call(Event.new(type: :approval_needed, tool_call: call))
275
294
  end
276
- parked
295
+ [parked, executed.any? { |call| ends_turn?(call) }]
277
296
  end
278
297
 
298
+ # Returns the calls that actually executed (denied ones only answer).
279
299
  def settle(open, signal, &emit)
280
300
  approved, denied = open.partition { |approval| approval[:decision]["approved"] }
281
301
  cleared = screen(approved.map { |approval| approval[:call] }, signal, &emit)
282
- execute(cleared, signal, &emit)
302
+ executed = execute(cleared, signal, &emit)
283
303
  denied.each do |approval|
284
304
  note = approval[:decision]["note"]
285
305
  text = "The user denied this tool call#{note ? ": #{note}" : "."}"
286
306
  answer(approval[:call], text, &emit)
287
307
  end
308
+ executed
288
309
  end
289
310
 
311
+ # Runs the calls and answers each; returns the calls that executed
312
+ # (blocked and parked calls never reach here).
290
313
  def execute(calls, signal, &emit)
291
- return if calls.empty?
314
+ return [] if calls.empty?
292
315
 
293
316
  results = ToolExecutor.call(calls, @tools_by_name, signal: signal,
294
317
  max_concurrency: @max_concurrency,
@@ -299,6 +322,7 @@ module Mistri
299
322
  result = rewrite(call, result, context) if @after_tool
300
323
  answer(call, result, duration: seconds, &emit)
301
324
  end
325
+ calls
302
326
  end
303
327
 
304
328
  # A blocked call answers in band, so the model reads the reason and
@@ -347,10 +371,22 @@ module Mistri
347
371
  tool ? tool.needs_approval?(call.arguments) : false
348
372
  end
349
373
 
350
- 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)
351
385
  status = { StopReason::ABORTED => :aborted, StopReason::BUDGET => :budget,
352
386
  StopReason::ERROR => :error }.fetch(message.stop_reason, :completed)
353
- 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)
354
390
  end
355
391
 
356
392
  def suspended(message, parked, usage)
@@ -367,35 +403,6 @@ module Mistri
367
403
  Result.new(message: message, status: :budget, usage: usage)
368
404
  end
369
405
 
370
- # Distinguishable from a parsed nil: JSON "null" is a valid value.
371
- PARSE_FAILED = Object.new.freeze
372
- private_constant :PARSE_FAILED
373
-
374
- def task_input(input, schema)
375
- "#{input}\n\nAnswer with ONLY a JSON value matching this schema:\n" \
376
- "#{JSON.generate(Schema.strict(schema))}"
377
- end
378
-
379
- def parse_output(text)
380
- body = text.to_s.strip
381
- body = body[/\A```(?:json)?\s*(.*?)```\z/m, 1] || body
382
- JSON.parse(body)
383
- rescue JSON::ParserError
384
- PARSE_FAILED
385
- end
386
-
387
- def task_errors(value, schema)
388
- return ["the answer is not valid JSON"] if value.equal?(PARSE_FAILED)
389
-
390
- Schema.violations(value, schema)
391
- end
392
-
393
- def fix_prompt(errors)
394
- lines = errors.map { |error| "- #{error}" }.join("\n")
395
- "Your answer did not satisfy the required output schema. Problems:\n" \
396
- "#{lines}\nReply with ONLY the corrected JSON."
397
- end
398
-
399
406
  def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
400
407
  end
401
408
  end