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 +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +23 -0
- data/lib/mistri/agent.rb +15 -27
- data/lib/mistri/event.rb +9 -5
- data/lib/mistri/mcp/client.rb +1 -1
- data/lib/mistri/retry_policy.rb +22 -0
- 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: 820753fb8dd64f485da060568e6dbc8a7af3fa24d63a2cc7dc957b5dbfc9f1d6
|
|
4
|
+
data.tar.gz: c3e7432802801d19979c3d2906f700fbe3170a1d4950d22f0da88e01b276b905
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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,
|
|
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
|
|
212
|
-
#
|
|
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, &
|
|
221
|
+
output_schema: output_schema, &gate)
|
|
222
222
|
attempt += 1
|
|
223
|
-
error =
|
|
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,
|
|
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,
|
|
22
|
-
# compaction,
|
|
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
|
|
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
|
data/lib/mistri/mcp/client.rb
CHANGED
|
@@ -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-
|
|
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
|
|
data/lib/mistri/retry_policy.rb
CHANGED
|
@@ -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
|
data/lib/mistri/version.rb
CHANGED