mistri 0.4.0 → 0.4.1

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: de242bfa63b5f170166c93613fc2a2807971d45b792a6200eb60805c4f41c977
4
- data.tar.gz: 362d523bba1848e2b552ece5906b1554c4a54820379c9f8e7a3e93430144a9c1
3
+ metadata.gz: 820753fb8dd64f485da060568e6dbc8a7af3fa24d63a2cc7dc957b5dbfc9f1d6
4
+ data.tar.gz: c3e7432802801d19979c3d2906f700fbe3170a1d4950d22f0da88e01b276b905
5
5
  SHA512:
6
- metadata.gz: 2ac330d28d201397f03912c893a538e824c5889a999f7afff3dde4cca8fe8e3a1263f4b886623f6c65fa6b732eae02962e72d4d9c6e945215f2aa724af1d82cb
7
- data.tar.gz: fa27dd203af02953b311a479992e82f87e526c8b825b6be057ec0c4bffc0a76172b19af11e57ed7c522a15d0f8e063724b74dd5c5173f11118e96736457c4453
6
+ metadata.gz: 78959db170082e7264a3657dec0ff1e21ebf496a2e2603fe98ca6a7b263f68f980456df3f2881d798f2a8e7a296902700b2dc4e155a9ef43f54a32df5ca75eb7
7
+ data.tar.gz: 565be0a187d2651d92d43e59351090e0d6623dff13d70bbd1747e5c35f5167f162a4182c111e265734eadbc9aa465be003d5704da61495af281b321036eb0f26
data/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.4.1] - 2026-07-06
9
+
10
+ - Terminal events are loop-owned: each attempt's :done or :error is held at
11
+ a gate and only the accepted attempt's terminal reaches the subscriber. A
12
+ transient failure that retries and recovers no longer shows the host an
13
+ error it then walks back, and repeated empty completions no longer emit a
14
+ :done per attempt.
15
+ - The :retry event carries structure: attempt, max_attempts, and delay ride
16
+ the event (and its wire form), so a sink can render "Retrying (2/3) in
17
+ 1.3s" without parsing prose. The prose note stays as content. The event
18
+ no longer carries a stop reason, because a retry is not a stop.
19
+ - RetryPolicy owns attempt classification: error_for(message) returns the
20
+ provider's error or a synthesized EmptyCompletion for a blank answer.
21
+ RetryPolicy::EMPTY_COMPLETION replaces the agent-internal constant.
22
+ - The MCP client advertises protocol 2025-11-25, the current spec revision
23
+ it already negotiated.
24
+ - README: the :retry event and the terminal-events invariant are documented
25
+ in the reliability section; the MCP section documents eager listing,
26
+ refresh:, and the duplicate-name raise at Agent.new.
27
+
8
28
  ## [0.4.0] - 2026-07-05
9
29
 
10
30
  - Mistri::Definition: agents as frontmatter markdown files. Config in
data/README.md CHANGED
@@ -304,6 +304,12 @@ tools = Mistri::MCP.tools(client, prefix: "linear",
304
304
  agent = Mistri.agent("claude-opus-4-8", tools: tools)
305
305
  ```
306
306
 
307
+ The bridge lists the server's tools once, at build time; `client.tools(refresh: true)`
308
+ re-lists when a host wants a changed toolset. `prefix:` namespaces local
309
+ names (`linear__create_issue`) because duplicate tool names raise at
310
+ `Agent.new`: collisions fail loud instead of one server's tool silently
311
+ shadowing another's.
312
+
307
313
  Local stdio servers spawn as child processes, credentials in their
308
314
  environment. That is also the whole "give the agent a browser" story:
309
315
 
@@ -369,6 +375,23 @@ budget = Mistri::Budget.new(turns: 20, cost_usd: 2.00)
369
375
  policy = Mistri::RetryPolicy.new(attempts: 3)
370
376
  ```
371
377
 
378
+ Retries are invisible to the model but not to your UI: each backoff emits a
379
+ `:retry` event carrying `attempt`, `max_attempts`, and `delay`, so a sink can
380
+ show a live "reconnecting" state instead of a silent spinner. Terminal events
381
+ are loop-owned: `:done` and `:error` reach the subscriber only for the
382
+ accepted attempt, so a recovered retry never flashes an error it then walks
383
+ back.
384
+
385
+ ```ruby
386
+ agent.run("Plan the itinerary.") do |event|
387
+ case event.type
388
+ when :text_delta then stream(event.delta)
389
+ when :retry then banner("Retrying (#{event.attempt}/#{event.max_attempts}) in #{event.delay}s")
390
+ when :done, :error then clear_banner
391
+ end
392
+ end
393
+ ```
394
+
372
395
  ## Images and provider options
373
396
 
374
397
  ```ruby
data/lib/mistri/agent.rb CHANGED
@@ -52,12 +52,6 @@ module Mistri
52
52
 
53
53
  attr_reader :session
54
54
 
55
- # What retries see when a completion answers nothing at all.
56
- EMPTY_COMPLETION_ERROR = {
57
- "type" => "EmptyCompletion",
58
- "message" => "the provider returned an empty completion"
59
- }.freeze
60
-
61
55
  # Run one exchange: append the user turn, then loop until the model
62
56
  # answers without tools, a gated tool suspends the run, the run aborts,
63
57
  # or a budget stops it.
@@ -66,10 +60,12 @@ module Mistri
66
60
  # on top; run alone does not validate.
67
61
  def run(input, images: [], signal: nil, output_schema: nil, &emit)
68
62
  if @session.open_approvals.any?
69
- raise ConfigurationError, "session is awaiting approval decisions; call resume"
63
+ raise ConfigurationError,
64
+ "session is awaiting approvals; call resume"
70
65
  end
71
66
  if input.to_s.empty? && Array(images).empty?
72
- raise ArgumentError, "run needs input text or images"
67
+ raise ArgumentError,
68
+ "run needs input text or images"
73
69
  end
74
70
 
75
71
  fold_steers # steers queued while idle arrived first; keep that order
@@ -208,44 +204,35 @@ module Mistri
208
204
  # the request.
209
205
  #
210
206
  # A transient failure retries the same request with backoff; the failed
211
- # attempt is recorded as a retry entry, never as a message, so retries
212
- # stay invisible to the model. Only the final outcome persists.
207
+ # attempt records as a retry entry, never as a message, and its terminal
208
+ # (:done or :error) holds at a gate, so retries stay invisible to the
209
+ # model and a recovered retry never shows the subscriber an error it
210
+ # walks back. Only the accepted attempt persists and terminates.
213
211
  def run_turn(signal, output_schema = nil, &emit)
214
212
  history = @transform_context.reduce(@session.messages) do |messages, transform|
215
213
  transform.call(messages)
216
214
  end
217
215
  attempt = 0
218
216
  loop do
217
+ held = nil
218
+ gate = emit && ->(event) { event.terminal? ? held = event : emit.call(event) }
219
219
  message = @provider.stream(messages: history, system: @system,
220
220
  tools: @tools.map(&:spec), signal: signal,
221
- output_schema: output_schema, &emit)
221
+ output_schema: output_schema, &gate)
222
222
  attempt += 1
223
- error = turn_error(message)
223
+ error = @retries&.error_for(message)
224
224
  if retry_turn?(error, attempt, signal)
225
225
  pause = @retries.delay(attempt, error["retry_after"])
226
226
  record_retry(message, error, attempt, pause, &emit)
227
227
  wait(pause, signal)
228
228
  next unless signal&.aborted?
229
229
  end
230
+ emit&.call(held) if held
230
231
  @session.append_message(message)
231
232
  return message
232
233
  end
233
234
  end
234
235
 
235
- # The provider's own error when the turn failed, or a synthesized one
236
- # for a completion that answers nothing: providers intermittently stop
237
- # with an empty candidate, and a retry usually clears it.
238
- def turn_error(message)
239
- return message.error if message.stop_reason == StopReason::ERROR
240
-
241
- EMPTY_COMPLETION_ERROR if empty_completion?(message)
242
- end
243
-
244
- def empty_completion?(message)
245
- message.stop_reason == StopReason::STOP &&
246
- message.content.all? { |block| block.is_a?(Content::Text) && block.text.to_s.strip.empty? }
247
- end
248
-
249
236
  def retry_turn?(error, attempt, signal)
250
237
  return false unless @retries && error
251
238
  return false if signal&.aborted?
@@ -258,7 +245,8 @@ module Mistri
258
245
  "delay" => pause.round(2))
259
246
  note = format("attempt %<attempt>d failed; retrying in %<pause>.1fs",
260
247
  attempt: attempt, pause: pause)
261
- emit&.call(Event.new(type: :retry, content: note, reason: StopReason::ERROR,
248
+ emit&.call(Event.new(type: :retry, content: note, attempt: attempt,
249
+ max_attempts: @retries.attempts, delay: pause.round(2),
262
250
  message: message))
263
251
  end
264
252
 
data/lib/mistri/event.rb CHANGED
@@ -15,11 +15,14 @@ module Mistri
15
15
  # events; nil where nothing ran (denials, interruptions).
16
16
  class Event < Data.define(:type, :content_index, :delta, :content, :tool_call,
17
17
  :reason, :message, :error_message, :partial, :origin,
18
- :duration)
18
+ :duration, :attempt, :max_attempts, :delay)
19
19
  # The stream types come from a provider mid-turn; the loop adds
20
20
  # :tool_result after it runs each tool, :approval_needed when a gated
21
- # call parks for a human, and :compacting/:compaction around a context
22
- # compaction, so one subscription sees the whole exchange.
21
+ # call parks for a human, :compacting/:compaction around a context
22
+ # compaction, and :retry (with attempt, max_attempts, delay) before it
23
+ # waits out a transient failure, so one subscription sees the whole
24
+ # exchange. :done and :error are loop-owned and terminal: only the
25
+ # accepted attempt's terminal event reaches the subscriber.
23
26
  TYPES = %i[
24
27
  start
25
28
  text_start text_delta text_end
@@ -33,7 +36,7 @@ module Mistri
33
36
 
34
37
  def initialize(type:, content_index: nil, delta: nil, content: nil, tool_call: nil,
35
38
  reason: nil, message: nil, error_message: nil, partial: nil, origin: nil,
36
- duration: nil)
39
+ duration: nil, attempt: nil, max_attempts: nil, delay: nil)
37
40
  raise ArgumentError, "unknown event type #{type.inspect}" unless TYPES.include?(type)
38
41
 
39
42
  super
@@ -48,7 +51,8 @@ module Mistri
48
51
  # Partials are ephemeral streaming state and stay out of serialization.
49
52
  def to_h
50
53
  { type:, content_index:, delta:, content:, tool_call: tool_call&.to_h,
51
- reason:, message: message&.to_h, error_message:, origin:, duration: }.compact
54
+ reason:, message: message&.to_h, error_message:, origin:, duration:,
55
+ attempt:, max_attempts:, delay: }.compact
52
56
  end
53
57
  end
54
58
  end
@@ -24,7 +24,7 @@ module Mistri
24
24
  # One client serializes its calls; parallel tool calls against one
25
25
  # server queue rather than interleave.
26
26
  class Client
27
- PROTOCOL_VERSION = "2025-06-18"
27
+ PROTOCOL_VERSION = "2025-11-25"
28
28
  SUPPORTED_VERSIONS = %w[2025-11-25 2025-06-18 2025-03-26 2024-11-05].freeze
29
29
  LOOPBACK = %w[localhost 127.0.0.1 ::1].freeze
30
30
 
@@ -21,10 +21,25 @@ module Mistri
21
21
  @max_delay = max_delay
22
22
  end
23
23
 
24
+ # What retries see when a completion answers nothing at all.
25
+ EMPTY_COMPLETION = {
26
+ "type" => "EmptyCompletion",
27
+ "message" => "the provider returned an empty completion"
28
+ }.freeze
29
+
24
30
  def retry?(error, attempt)
25
31
  attempt <= attempts && retryable?(error)
26
32
  end
27
33
 
34
+ # The error a finished attempt carries: the provider's own error, or a
35
+ # synthesized one for a completion that answers nothing at all, which a
36
+ # retry usually clears.
37
+ def error_for(message)
38
+ return message.error if message.stop_reason == StopReason::ERROR
39
+
40
+ EMPTY_COMPLETION if empty?(message)
41
+ end
42
+
28
43
  # error is the ErrorData hash from an errored message. A status decides
29
44
  # when present; otherwise only known-transient types retry, so schema
30
45
  # violations and host bugs never loop.
@@ -43,5 +58,12 @@ module Mistri
43
58
  exponential = base * (2**(attempt - 1))
44
59
  (exponential * rand(0.5..1.0)).clamp(0.0, max_delay)
45
60
  end
61
+
62
+ private
63
+
64
+ def empty?(message)
65
+ message.stop_reason == StopReason::STOP &&
66
+ message.content.all? { |block| block.is_a?(Content::Text) && block.text.to_s.strip.empty? }
67
+ end
46
68
  end
47
69
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mistri
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.1"
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.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Muhammad Ahmed Cheema