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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e553e955f2a1263afed1b26889dbf04773916dbd6f33f9229e6db2b729dd655b
4
- data.tar.gz: 98b95079d9f91b068580ac2708c362b1317452378eba99fe7643e301660ad89d
3
+ metadata.gz: 05f65dbeaf12ad0dc59334e300a2ecd2febc01607d528c0d8e7a95003c49e608
4
+ data.tar.gz: f1c261e05ee6e433f3b08f6c129841b6b25a44ee935671d3314681de792d0da5
5
5
  SHA512:
6
- metadata.gz: cb690580fe35c2226bfa0d86d23f77c9236e8dcb10ba3af0a7753a1d52b96c11e951d41ee647d0cf947dae1d3c82ca834105070099dec0c4bac728bcd296698b
7
- data.tar.gz: 9e5be1a7fde00c351abc5f123ddfa82db8cb0ae34f14b0c6aa474b12338c587958c547d68e70280de7cda780ea4e674953e0f28681d33c17b37edd8ea1672d8e
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 host-allowlisted model per child.
234
- Several spawns in one turn fan out in parallel:
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. Scenarios
355
- assert that coined codenames (a ghost of a word like `Wraithowyn` exists in
356
- no training data) flowed through tool results, summaries, and child agents:
357
- proof of information flow, not model knowledge.
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
- $ MISTRI_INTEGRATION_MODELS=claude-opus-4-8,gpt-5.5 bundle exec rake integration
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
- `0.2.0`: an MCP client bridge, so any MCP server's tools plug into an agent.
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
- if retry_turn?(message, attempt, signal)
216
- pause = @retries.delay(attempt, message.error&.dig("retry_after"))
217
- record_retry(message, attempt, pause, &emit)
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
- def retry_turn?(message, attempt, signal)
227
- return false unless @retries && message.stop_reason == StopReason::ERROR
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?(message.error, attempt)
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" => message.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
- turns = groups.filter_map do |group|
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)
@@ -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
 
@@ -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
- compaction = last_compaction
55
+ log = entries
56
+ compaction = log.reverse_each.find { |entry| entry["type"] == "compaction" }
49
57
  from = compaction ? compaction["kept_from"] : 0
50
- pairs = entries.each_with_index.filter_map do |entry, index|
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
@@ -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 writes the child's
22
- # instructions, picks a tool subset, and may name a model:
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 grant a tool subset by name; models: is the
88
- # host's allowlist of child model ids without one, no model choice
89
- # is offered at all, so a hallucinated id can never construct a
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
- schema: spawner_schema(tools, models)) do |args, context|
100
- run_child(label: "spawn", provider: child_provider(provider, args["model"], models),
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mistri
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mistri
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Muhammad Ahmed Cheema