mistri 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +132 -4
- data/README.md +88 -7
- data/lib/mistri/abort_signal.rb +10 -0
- data/lib/mistri/agent.rb +70 -63
- data/lib/mistri/child.rb +130 -0
- data/lib/mistri/compactor.rb +1 -1
- data/lib/mistri/console.rb +188 -0
- data/lib/mistri/dispatchers.rb +47 -0
- data/lib/mistri/event.rb +9 -3
- data/lib/mistri/locks/rails_cache.rb +48 -0
- data/lib/mistri/locks.rb +141 -0
- data/lib/mistri/mcp.rb +1 -1
- data/lib/mistri/providers/fake.rb +16 -2
- data/lib/mistri/result.rb +7 -2
- data/lib/mistri/retry_policy.rb +2 -2
- data/lib/mistri/session.rb +95 -5
- data/lib/mistri/skill.rb +1 -1
- data/lib/mistri/skills.rb +1 -1
- data/lib/mistri/spawner.rb +266 -0
- data/lib/mistri/stores/active_record.rb +21 -6
- data/lib/mistri/stores/jsonl.rb +3 -1
- data/lib/mistri/sub_agent.rb +191 -93
- data/lib/mistri/task_output.rb +40 -0
- data/lib/mistri/tool.rb +10 -1
- data/lib/mistri/tool_context.rb +3 -3
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri.rb +11 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d46cab51a8a899ddd2e3cd0284a7e9eb4185d1362af158303adb97c2a404a657
|
|
4
|
+
data.tar.gz: 548bf1b8cdc9d22f304674eb5b876b624c16f6b3cc31839daba83909f78641a5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
## [
|
|
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
|
|
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
|
|
168
|
-
frames to any IO), and Coalesced (merges delta bursts to UI speed)
|
|
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
|
-
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
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.
|
|
409
|
-
|
|
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
|
data/lib/mistri/abort_signal.rb
CHANGED
|
@@ -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.
|
|
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
|
|
@@ -68,7 +72,7 @@ module Mistri
|
|
|
68
72
|
"run needs input text or images"
|
|
69
73
|
end
|
|
70
74
|
|
|
71
|
-
|
|
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(
|
|
104
|
-
|
|
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 =
|
|
111
|
-
errors =
|
|
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
|
-
|
|
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
|
|
163
|
-
# more turn so it
|
|
164
|
-
#
|
|
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.
|
|
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
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
#
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
262
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|