mistri 0.2.1 → 0.3.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 +19 -0
- data/README.md +14 -8
- data/lib/mistri/agent.rb +29 -8
- data/lib/mistri/providers/gemini/serializer.rb +4 -13
- data/lib/mistri/retry_policy.rb +1 -1
- data/lib/mistri/session.rb +34 -3
- data/lib/mistri/sub_agent.rb +26 -13
- data/lib/mistri/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 05f65dbeaf12ad0dc59334e300a2ecd2febc01607d528c0d8e7a95003c49e608
|
|
4
|
+
data.tar.gz: f1c261e05ee6e433f3b08f6c129841b6b25a44ee935671d3314681de792d0da5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 852c0bc7b87c7aa8b09b6862b88babbdbe3ddd6bd3e9736d8429818a3daaa98279fbe4f7703fd45e371ab78d7e76b0660ee19c47f183c760bcdba41d58bb6929
|
|
7
|
+
data.tar.gz: 2e97b19dfee548042488c16d4d37ddcdf5adf3959de7cd90d076e29c667021f2c07aec603eb8d6d0316ce944a027b55da9ca13f2adf3bc1647dac38b16316c0c
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.3.0] - 2026-07-05
|
|
9
|
+
|
|
10
|
+
- Sessions heal at replay: a run killed mid-tool (deploy, crash) leaves
|
|
11
|
+
tool calls without results, which providers reject on every later turn.
|
|
12
|
+
Unsettled calls now replay with a synthesized interrupted result; calls
|
|
13
|
+
parked for human approval stay open for resume. The stored log is never
|
|
14
|
+
rewritten, and context assembly now reads the store exactly once.
|
|
15
|
+
- Empty completions retry: a turn that answers with no text, thinking, or
|
|
16
|
+
tool calls (an intermittent provider behavior) now retries under the
|
|
17
|
+
standard policy instead of ending the run in silence.
|
|
18
|
+
- Gemini: consecutive user turns are no longer merged. Mixing a text part
|
|
19
|
+
into a functionResponse turn makes Gemini answer an empty candidate, so
|
|
20
|
+
steers and resumed prompts stay their own turns; Gemini accepts this.
|
|
21
|
+
- The spawn tool takes an optional `name`: the model labels each worker,
|
|
22
|
+
and the label rides origin tags and the transcript link, so fan-out
|
|
23
|
+
streams read as `pricing-scout#a41f` instead of `spawn#a41f`.
|
|
24
|
+
- The spawn tool's `model` parameter names the default child model in its
|
|
25
|
+
description, so the model's choice (or non-choice) is informed.
|
|
26
|
+
|
|
8
27
|
## [0.2.1] - 2026-07-05
|
|
9
28
|
|
|
10
29
|
- The gem homepage and documentation links now point at
|
data/README.md
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
<p align="center"><strong>mistri</strong>, the agent harness for Ruby applications.</p>
|
|
9
9
|
|
|
10
|
+
<p align="center"><a href="https://mistri.sh">mistri.sh</a> · <a href="https://mistri.sh/docs/getting-started/">docs</a></p>
|
|
11
|
+
|
|
10
12
|
<p align="center">
|
|
11
13
|
<a href="https://rubygems.org/gems/mistri"><img alt="Gem Version" src="https://img.shields.io/gem/v/mistri"></a>
|
|
12
14
|
<a href="https://github.com/mcheemaa/mistri/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/mcheemaa/mistri/actions/workflows/ci.yml/badge.svg"></a>
|
|
@@ -230,8 +232,9 @@ agent = Mistri.agent("claude-opus-4-8", tools: [researcher.tool])
|
|
|
230
232
|
```
|
|
231
233
|
|
|
232
234
|
Or hand the model an open spawn tool and let it compose its own workers:
|
|
233
|
-
instructions, a tool subset, and a
|
|
234
|
-
Several spawns in one turn fan out in
|
|
235
|
+
a name for the event stream, instructions, a tool subset, and a
|
|
236
|
+
host-allowlisted model per child. Several spawns in one turn fan out in
|
|
237
|
+
parallel:
|
|
235
238
|
|
|
236
239
|
```ruby
|
|
237
240
|
spawn = Mistri::SubAgent.spawner(provider: provider, tools: [fetch_page, search])
|
|
@@ -351,18 +354,21 @@ Mistri.agent("claude-opus-4-8", provider_options: { cache: false })
|
|
|
351
354
|
## Testing
|
|
352
355
|
|
|
353
356
|
`rake test` is hermetic and fast. `rake integration` runs every feature end
|
|
354
|
-
to end against real provider APIs, once per model in the matrix
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
357
|
+
to end against real provider APIs, once per model in the matrix: an
|
|
358
|
+
Anthropic, an OpenAI, and a Gemini model by default. Scenarios assert that
|
|
359
|
+
coined codenames (a ghost of a word like `Wraithowyn` exists in no training
|
|
360
|
+
data) flowed through tool results, summaries, and child agents: proof of
|
|
361
|
+
information flow, not model knowledge.
|
|
358
362
|
|
|
359
363
|
```console
|
|
360
|
-
$
|
|
364
|
+
$ bundle exec rake integration
|
|
365
|
+
$ MISTRI_INTEGRATION_MODELS=claude-opus-4-8 bundle exec rake integration
|
|
361
366
|
```
|
|
362
367
|
|
|
363
368
|
## Roadmap
|
|
364
369
|
|
|
365
|
-
|
|
370
|
+
Next up: strict tool schemas, provider-native MCP passthrough, and the
|
|
371
|
+
hardening that falls out of the first production applications.
|
|
366
372
|
|
|
367
373
|
## Credits
|
|
368
374
|
|
data/lib/mistri/agent.rb
CHANGED
|
@@ -50,6 +50,12 @@ module Mistri
|
|
|
50
50
|
|
|
51
51
|
attr_reader :session
|
|
52
52
|
|
|
53
|
+
# What retries see when a completion answers nothing at all.
|
|
54
|
+
EMPTY_COMPLETION_ERROR = {
|
|
55
|
+
"type" => "EmptyCompletion",
|
|
56
|
+
"message" => "the provider returned an empty completion"
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
53
59
|
# Run one exchange: append the user turn, then loop until the model
|
|
54
60
|
# answers without tools, a gated tool suspends the run, the run aborts,
|
|
55
61
|
# or a budget stops it.
|
|
@@ -212,9 +218,10 @@ module Mistri
|
|
|
212
218
|
tools: @tools.map(&:spec), signal: signal,
|
|
213
219
|
output_schema: output_schema, &emit)
|
|
214
220
|
attempt += 1
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
221
|
+
error = turn_error(message)
|
|
222
|
+
if retry_turn?(error, attempt, signal)
|
|
223
|
+
pause = @retries.delay(attempt, error["retry_after"])
|
|
224
|
+
record_retry(message, error, attempt, pause, &emit)
|
|
218
225
|
wait(pause, signal)
|
|
219
226
|
next unless signal&.aborted?
|
|
220
227
|
end
|
|
@@ -223,15 +230,29 @@ module Mistri
|
|
|
223
230
|
end
|
|
224
231
|
end
|
|
225
232
|
|
|
226
|
-
|
|
227
|
-
|
|
233
|
+
# The provider's own error when the turn failed, or a synthesized one
|
|
234
|
+
# for a completion that answers nothing: providers intermittently stop
|
|
235
|
+
# with an empty candidate, and a retry usually clears it.
|
|
236
|
+
def turn_error(message)
|
|
237
|
+
return message.error if message.stop_reason == StopReason::ERROR
|
|
238
|
+
|
|
239
|
+
EMPTY_COMPLETION_ERROR if empty_completion?(message)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def empty_completion?(message)
|
|
243
|
+
message.stop_reason == StopReason::STOP &&
|
|
244
|
+
message.content.all? { |block| block.is_a?(Content::Text) && block.text.to_s.strip.empty? }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def retry_turn?(error, attempt, signal)
|
|
248
|
+
return false unless @retries && error
|
|
228
249
|
return false if signal&.aborted?
|
|
229
250
|
|
|
230
|
-
@retries.retry?(
|
|
251
|
+
@retries.retry?(error, attempt)
|
|
231
252
|
end
|
|
232
253
|
|
|
233
|
-
def record_retry(message, attempt, pause, &emit)
|
|
234
|
-
@session.append("retry", "attempt" => attempt, "error" =>
|
|
254
|
+
def record_retry(message, error, attempt, pause, &emit)
|
|
255
|
+
@session.append("retry", "attempt" => attempt, "error" => error,
|
|
235
256
|
"delay" => pause.round(2))
|
|
236
257
|
note = format("attempt %<attempt>d failed; retrying in %<pause>.1fs",
|
|
237
258
|
attempt: attempt, pause: pause)
|
|
@@ -10,26 +10,17 @@ module Mistri
|
|
|
10
10
|
# thought signatures echo back verbatim on the exact part they arrived
|
|
11
11
|
# with, but only for messages this provider produced; a foreign
|
|
12
12
|
# signature would be rejected. Thinking summaries are output-only and
|
|
13
|
-
# never replay.
|
|
13
|
+
# never replay. Consecutive user turns stay separate: Gemini accepts
|
|
14
|
+
# them, and mixing a text part into a functionResponse turn makes it
|
|
15
|
+
# answer an empty candidate.
|
|
14
16
|
module Serializer
|
|
15
17
|
module_function
|
|
16
18
|
|
|
17
19
|
def contents(history)
|
|
18
20
|
groups = history.reject(&:system?).chunk_while { |a, b| a.tool? && b.tool? }
|
|
19
|
-
|
|
21
|
+
groups.filter_map do |group|
|
|
20
22
|
group.first.tool? ? tool_turn(group) : turn(group.first)
|
|
21
23
|
end
|
|
22
|
-
merge_user_runs(turns)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# A steered run puts a user message right behind tool results, and
|
|
26
|
-
# both serialize as user turns. Gemini expects turns to alternate, so
|
|
27
|
-
# consecutive user turns merge into one.
|
|
28
|
-
def merge_user_runs(turns)
|
|
29
|
-
turns.chunk_while { |a, b| a[:role] == "user" && b[:role] == "user" }
|
|
30
|
-
.map do |run|
|
|
31
|
-
run.length == 1 ? run.first : { role: "user", parts: run.flat_map { |t| t[:parts] } }
|
|
32
|
-
end
|
|
33
24
|
end
|
|
34
25
|
|
|
35
26
|
def system_instruction(system)
|
data/lib/mistri/retry_policy.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Mistri
|
|
|
11
11
|
class RetryPolicy
|
|
12
12
|
RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504, 529].freeze
|
|
13
13
|
RETRYABLE_TYPES = %w[ProviderError RateLimitError OverloadedError ServerError
|
|
14
|
-
TruncatedStream].freeze
|
|
14
|
+
TruncatedStream EmptyCompletion].freeze
|
|
15
15
|
|
|
16
16
|
attr_reader :attempts, :base, :max_delay
|
|
17
17
|
|
data/lib/mistri/session.rb
CHANGED
|
@@ -40,16 +40,25 @@ module Mistri
|
|
|
40
40
|
# The conversation as the model replays it.
|
|
41
41
|
def messages = replay.map(&:first)
|
|
42
42
|
|
|
43
|
+
# What a run killed mid-tool answers in place of the result it never got.
|
|
44
|
+
INTERRUPTED_RESULT = "[interrupted: the run stopped before this tool returned]"
|
|
45
|
+
|
|
43
46
|
# Replay messages paired with the entry index each came from, starting at
|
|
44
47
|
# the latest compaction boundary. The synthetic summary message carries a
|
|
45
48
|
# nil index. Compaction places its cuts by these indexes; the full entry
|
|
46
|
-
# log stays in the store for transcript views.
|
|
49
|
+
# log stays in the store for transcript views. One store read builds the
|
|
50
|
+
# whole context, healed: a crash that left tool calls unanswered would
|
|
51
|
+
# brick every later turn with a provider rejection, so unsettled calls
|
|
52
|
+
# get a synthesized interrupted result. Calls parked for human approval
|
|
53
|
+
# stay open; resume owns those.
|
|
47
54
|
def replay
|
|
48
|
-
|
|
55
|
+
log = entries
|
|
56
|
+
compaction = log.reverse_each.find { |entry| entry["type"] == "compaction" }
|
|
49
57
|
from = compaction ? compaction["kept_from"] : 0
|
|
50
|
-
pairs =
|
|
58
|
+
pairs = log.each_with_index.filter_map do |entry, index|
|
|
51
59
|
[Message.from_h(entry["message"]), index] if index >= from && entry["type"] == "message"
|
|
52
60
|
end
|
|
61
|
+
pairs = heal(pairs, parked_call_ids(log))
|
|
53
62
|
compaction ? [[summary_message(compaction["summary"]), nil], *pairs] : pairs
|
|
54
63
|
end
|
|
55
64
|
|
|
@@ -102,6 +111,28 @@ module Mistri
|
|
|
102
111
|
|
|
103
112
|
private
|
|
104
113
|
|
|
114
|
+
def heal(pairs, parked)
|
|
115
|
+
answered = pairs.map(&:first).select(&:tool?).to_set(&:tool_call_id)
|
|
116
|
+
pairs.flat_map do |message, index|
|
|
117
|
+
dangling = if message.assistant?
|
|
118
|
+
message.tool_calls.reject do |call|
|
|
119
|
+
answered.include?(call.id) || parked.include?(call.id)
|
|
120
|
+
end
|
|
121
|
+
else
|
|
122
|
+
[]
|
|
123
|
+
end
|
|
124
|
+
[[message, index]] + dangling.map do |call|
|
|
125
|
+
[Message.tool(content: INTERRUPTED_RESULT, tool_call_id: call.id,
|
|
126
|
+
tool_name: call.name), nil]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def parked_call_ids(log)
|
|
132
|
+
log.filter_map { |entry| entry.dig("call", "id") if entry["type"] == "approval_request" }
|
|
133
|
+
.to_set
|
|
134
|
+
end
|
|
135
|
+
|
|
105
136
|
def summary_message(summary)
|
|
106
137
|
Message.user("#{Compaction::SUMMARY_PREFACE}\n\n#{summary}")
|
|
107
138
|
end
|
data/lib/mistri/sub_agent.rb
CHANGED
|
@@ -18,8 +18,8 @@ module Mistri
|
|
|
18
18
|
# )
|
|
19
19
|
# agent = Mistri::Agent.new(provider:, tools: [researcher.tool])
|
|
20
20
|
#
|
|
21
|
-
# and the open spawn tool, where the model
|
|
22
|
-
# instructions, picks a tool subset, and may
|
|
21
|
+
# and the open spawn tool, where the model composes each worker: names
|
|
22
|
+
# it, writes its instructions, picks a tool subset, and may pick a model:
|
|
23
23
|
#
|
|
24
24
|
# spawn = Mistri::SubAgent.spawner(provider:, tools: [fetch_page, search])
|
|
25
25
|
#
|
|
@@ -84,9 +84,10 @@ module Mistri
|
|
|
84
84
|
|
|
85
85
|
class << self
|
|
86
86
|
# The open spawn tool over a pool of tools the host allows children to
|
|
87
|
-
# use. The model may
|
|
88
|
-
#
|
|
89
|
-
#
|
|
87
|
+
# use. The model may name the worker (a display label riding origins
|
|
88
|
+
# and the transcript link) and grant a tool subset by name; models: is
|
|
89
|
+
# the host's allowlist of child model ids — without one, no model
|
|
90
|
+
# choice is offered at all, so a hallucinated id can never construct a
|
|
90
91
|
# provider or land children on an expensive model.
|
|
91
92
|
def spawner(provider:, tools: [], models: [], needs_approval: false, **agent_options)
|
|
92
93
|
forbid_gated!(tools)
|
|
@@ -94,10 +95,11 @@ module Mistri
|
|
|
94
95
|
raise ConfigurationError, "the spawn tool never goes in its own pool"
|
|
95
96
|
end
|
|
96
97
|
|
|
98
|
+
schema = spawner_schema(tools, models, default_model(provider))
|
|
97
99
|
Tool.define("spawn_agent", SPAWNER_DESCRIPTION,
|
|
98
|
-
needs_approval: needs_approval,
|
|
99
|
-
|
|
100
|
-
|
|
100
|
+
needs_approval: needs_approval, schema: schema) do |args, context|
|
|
101
|
+
run_child(label: child_label(args["name"]),
|
|
102
|
+
provider: child_provider(provider, args["model"], models),
|
|
101
103
|
system: args.fetch("instructions"),
|
|
102
104
|
tools: pick(tools, args["tools"]),
|
|
103
105
|
task: args.fetch("task"), context: context, **agent_options)
|
|
@@ -173,22 +175,33 @@ module Mistri
|
|
|
173
175
|
end
|
|
174
176
|
end
|
|
175
177
|
|
|
176
|
-
def spawner_schema(pool, models)
|
|
178
|
+
def spawner_schema(pool, models, default)
|
|
177
179
|
tool_names = pool.map(&:name)
|
|
180
|
+
fallback = default ? " (default: #{default})" : ""
|
|
178
181
|
lambda do
|
|
182
|
+
string :name, "A short name for this worker, shown wherever its events appear"
|
|
179
183
|
string :task, "The child's complete task", required: true
|
|
180
184
|
string :instructions, "The child's system prompt", required: true
|
|
181
185
|
if tool_names.any?
|
|
182
186
|
array :tools, "Subset of tools to grant (default: all)",
|
|
183
187
|
items: { type: "string", enum: tool_names }
|
|
184
188
|
end
|
|
185
|
-
if models.any?
|
|
186
|
-
string :model, "Model for the child (default: the configured one)",
|
|
187
|
-
enum: models
|
|
188
|
-
end
|
|
189
|
+
string :model, "Model for the child#{fallback}", enum: models if models.any?
|
|
189
190
|
end
|
|
190
191
|
end
|
|
191
192
|
|
|
193
|
+
def default_model(provider)
|
|
194
|
+
provider.model if provider.respond_to?(:model)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The label rides origins as "label#id" and joins nesting with ">",
|
|
198
|
+
# so those separators squeeze to hyphens along with whitespace.
|
|
199
|
+
def child_label(raw)
|
|
200
|
+
label = raw.to_s.gsub(/[#>\s]+/, "-").squeeze("-")[0, 32]
|
|
201
|
+
label = label.delete_prefix("-").delete_suffix("-")
|
|
202
|
+
label.empty? ? "spawn" : label
|
|
203
|
+
end
|
|
204
|
+
|
|
192
205
|
def child_provider(default, requested, models)
|
|
193
206
|
return default if requested.nil? || requested.to_s.empty?
|
|
194
207
|
unless models.include?(requested)
|
data/lib/mistri/version.rb
CHANGED