mistri 0.3.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 +34 -0
- data/README.md +57 -5
- data/lib/mistri/agent.rb +21 -30
- data/lib/mistri/content.rb +9 -0
- data/lib/mistri/definition.rb +80 -0
- data/lib/mistri/event.rb +9 -5
- data/lib/mistri/mcp/client.rb +1 -1
- data/lib/mistri/providers/openai/assembler.rb +13 -3
- data/lib/mistri/retry_policy.rb +22 -0
- data/lib/mistri/tool_context.rb +18 -7
- data/lib/mistri/tool_executor.rb +4 -2
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri.rb +1 -0
- metadata +2 -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,40 @@ 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
|
+
|
|
28
|
+
## [0.4.0] - 2026-07-05
|
|
29
|
+
|
|
30
|
+
- Mistri::Definition: agents as frontmatter markdown files. Config in
|
|
31
|
+
YAML, prompt in the body, {placeholders} filled at build time and
|
|
32
|
+
unfilled ones raise. Tool names and extra keys stay the host's
|
|
33
|
+
vocabulary.
|
|
34
|
+
- Agent context: Mistri.agent(context: anything) rides the run and reaches
|
|
35
|
+
every tool handler and hook as context.app, untouched.
|
|
36
|
+
- Content::Image.from_data_uri accepts base64 data: URIs directly.
|
|
37
|
+
|
|
38
|
+
- OpenAI reasoning summaries keep their paragraph structure: a reasoning
|
|
39
|
+
item's summary parts join with a blank line, and the boundary streams as
|
|
40
|
+
a thinking delta, so live views match the finished text.
|
|
41
|
+
|
|
8
42
|
## [0.3.0] - 2026-07-05
|
|
9
43
|
|
|
10
44
|
- Sessions heal at replay: a run killed mid-tool (deploy, crash) leaves
|
data/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
<p align="center">
|
|
13
13
|
<a href="https://rubygems.org/gems/mistri"><img alt="Gem Version" src="https://img.shields.io/gem/v/mistri"></a>
|
|
14
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>
|
|
15
|
-
<a href="https://codecov.io/gh/mcheemaa/mistri"><img alt="Coverage" src="https://
|
|
15
|
+
<a href="https://codecov.io/gh/mcheemaa/mistri"><img alt="Coverage" src="https://img.shields.io/codecov/c/github/mcheemaa/mistri"></a>
|
|
16
16
|
<a href="mistri.gemspec"><img alt="Ruby >= 3.2" src="https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D"></a>
|
|
17
17
|
<a href="Gemfile"><img alt="Runtime dependencies: zero" src="https://img.shields.io/badge/runtime_deps-0-brightgreen"></a>
|
|
18
18
|
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg"></a>
|
|
@@ -102,6 +102,20 @@ Mistri::Tool.define("edit_page", "Applies a page edit.") do |args|
|
|
|
102
102
|
end
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
+
Handlers and hooks can take the run's context as a second argument, and
|
|
106
|
+
`context.app` carries whatever object you pass as `Mistri.agent(context:)`
|
|
107
|
+
— the acting user, a tenant, a request — so tools stay authorization-aware
|
|
108
|
+
without closure gymnastics:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
agent = Mistri.agent("claude-opus-4-8", tools: tools,
|
|
112
|
+
context: { traveler: current_traveler })
|
|
113
|
+
|
|
114
|
+
Mistri::Tool.define("book_hotel", "Books the chosen hotel.") do |args, context|
|
|
115
|
+
Bookings.create!(args, traveler: context.app[:traveler])
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
105
119
|
## Human approval
|
|
106
120
|
|
|
107
121
|
Mark a tool `needs_approval: true` (or a predicate on its arguments) and the
|
|
@@ -110,12 +124,12 @@ The decision is a one-line session write from any process, any time later;
|
|
|
110
124
|
`resume` settles it and carries on.
|
|
111
125
|
|
|
112
126
|
```ruby
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
127
|
+
book_hotel = Mistri::Tool.define("book_hotel", "Books the chosen hotel.",
|
|
128
|
+
needs_approval: ->(args) { args["total_usd"].to_i > 500 }) do |args|
|
|
129
|
+
Bookings.create!(args)
|
|
116
130
|
end
|
|
117
131
|
|
|
118
|
-
result = agent.run("
|
|
132
|
+
result = agent.run("Book the corner suite for the Lisbon trip")
|
|
119
133
|
result.awaiting_approval? # => true; nothing executed
|
|
120
134
|
|
|
121
135
|
# Days later, in a controller:
|
|
@@ -215,6 +229,20 @@ A skill is a `SKILL.md` (or flat `.md`) with `name:`/`description:`
|
|
|
215
229
|
frontmatter, or built from database rows with
|
|
216
230
|
`Mistri::Skill.new(name:, description:, body:)`.
|
|
217
231
|
|
|
232
|
+
## Definitions
|
|
233
|
+
|
|
234
|
+
An agent as a markdown file: YAML frontmatter for config, the body as the
|
|
235
|
+
prompt, `{placeholders}` filled at build time (unfilled ones raise). Tool
|
|
236
|
+
names and any extra keys stay your vocabulary; the gem only reads the
|
|
237
|
+
file.
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
definition = Mistri::Definition.load("app/agents/trip_planner.md")
|
|
241
|
+
agent = Mistri.agent(definition.model,
|
|
242
|
+
system: definition.render(first_name: traveler.first_name),
|
|
243
|
+
tools: registry.build(definition.tools, traveler))
|
|
244
|
+
```
|
|
245
|
+
|
|
218
246
|
## Sub-agents
|
|
219
247
|
|
|
220
248
|
Delegate to a child agent with a clean context: exploration fills the
|
|
@@ -276,6 +304,12 @@ tools = Mistri::MCP.tools(client, prefix: "linear",
|
|
|
276
304
|
agent = Mistri.agent("claude-opus-4-8", tools: tools)
|
|
277
305
|
```
|
|
278
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
|
+
|
|
279
313
|
Local stdio servers spawn as child processes, credentials in their
|
|
280
314
|
environment. That is also the whole "give the agent a browser" story:
|
|
281
315
|
|
|
@@ -341,10 +375,28 @@ budget = Mistri::Budget.new(turns: 20, cost_usd: 2.00)
|
|
|
341
375
|
policy = Mistri::RetryPolicy.new(attempts: 3)
|
|
342
376
|
```
|
|
343
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
|
+
|
|
344
395
|
## Images and provider options
|
|
345
396
|
|
|
346
397
|
```ruby
|
|
347
398
|
photo = Mistri::Content::Image.from_bytes(File.binread("chart.png"), mime_type: "image/png")
|
|
399
|
+
photo = Mistri::Content::Image.from_data_uri(params[:image]) # canvases and uploads
|
|
348
400
|
agent.run("What trend does this chart show?", images: [photo])
|
|
349
401
|
|
|
350
402
|
Mistri.agent("gpt-5.5", provider_options: { reasoning: { effort: "high" } })
|
data/lib/mistri/agent.rb
CHANGED
|
@@ -30,7 +30,8 @@ module Mistri
|
|
|
30
30
|
# return a replacement result, or nil to keep the original.
|
|
31
31
|
def initialize(provider:, session: nil, system: nil, tools: [], budget: nil,
|
|
32
32
|
max_concurrency: 4, transform_context: nil, compaction: Compaction.new,
|
|
33
|
-
retries: RetryPolicy.new, skills: [], before_tool: nil, after_tool: nil
|
|
33
|
+
retries: RetryPolicy.new, skills: [], before_tool: nil, after_tool: nil,
|
|
34
|
+
context: nil)
|
|
34
35
|
@provider = provider
|
|
35
36
|
@session = session || Session.new(store: Stores::Memory.new)
|
|
36
37
|
skills = skills.is_a?(String) ? Skills.load(skills) : Array(skills)
|
|
@@ -46,16 +47,11 @@ module Mistri
|
|
|
46
47
|
@retries = retries || nil
|
|
47
48
|
@before_tool = before_tool
|
|
48
49
|
@after_tool = after_tool
|
|
50
|
+
@context = context
|
|
49
51
|
end
|
|
50
52
|
|
|
51
53
|
attr_reader :session
|
|
52
54
|
|
|
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
|
-
|
|
59
55
|
# Run one exchange: append the user turn, then loop until the model
|
|
60
56
|
# answers without tools, a gated tool suspends the run, the run aborts,
|
|
61
57
|
# or a budget stops it.
|
|
@@ -64,10 +60,12 @@ module Mistri
|
|
|
64
60
|
# on top; run alone does not validate.
|
|
65
61
|
def run(input, images: [], signal: nil, output_schema: nil, &emit)
|
|
66
62
|
if @session.open_approvals.any?
|
|
67
|
-
raise ConfigurationError,
|
|
63
|
+
raise ConfigurationError,
|
|
64
|
+
"session is awaiting approvals; call resume"
|
|
68
65
|
end
|
|
69
66
|
if input.to_s.empty? && Array(images).empty?
|
|
70
|
-
raise ArgumentError,
|
|
67
|
+
raise ArgumentError,
|
|
68
|
+
"run needs input text or images"
|
|
71
69
|
end
|
|
72
70
|
|
|
73
71
|
fold_steers # steers queued while idle arrived first; keep that order
|
|
@@ -206,44 +204,35 @@ module Mistri
|
|
|
206
204
|
# the request.
|
|
207
205
|
#
|
|
208
206
|
# A transient failure retries the same request with backoff; the failed
|
|
209
|
-
# attempt
|
|
210
|
-
#
|
|
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.
|
|
211
211
|
def run_turn(signal, output_schema = nil, &emit)
|
|
212
212
|
history = @transform_context.reduce(@session.messages) do |messages, transform|
|
|
213
213
|
transform.call(messages)
|
|
214
214
|
end
|
|
215
215
|
attempt = 0
|
|
216
216
|
loop do
|
|
217
|
+
held = nil
|
|
218
|
+
gate = emit && ->(event) { event.terminal? ? held = event : emit.call(event) }
|
|
217
219
|
message = @provider.stream(messages: history, system: @system,
|
|
218
220
|
tools: @tools.map(&:spec), signal: signal,
|
|
219
|
-
output_schema: output_schema, &
|
|
221
|
+
output_schema: output_schema, &gate)
|
|
220
222
|
attempt += 1
|
|
221
|
-
error =
|
|
223
|
+
error = @retries&.error_for(message)
|
|
222
224
|
if retry_turn?(error, attempt, signal)
|
|
223
225
|
pause = @retries.delay(attempt, error["retry_after"])
|
|
224
226
|
record_retry(message, error, attempt, pause, &emit)
|
|
225
227
|
wait(pause, signal)
|
|
226
228
|
next unless signal&.aborted?
|
|
227
229
|
end
|
|
230
|
+
emit&.call(held) if held
|
|
228
231
|
@session.append_message(message)
|
|
229
232
|
return message
|
|
230
233
|
end
|
|
231
234
|
end
|
|
232
235
|
|
|
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
236
|
def retry_turn?(error, attempt, signal)
|
|
248
237
|
return false unless @retries && error
|
|
249
238
|
return false if signal&.aborted?
|
|
@@ -256,7 +245,8 @@ module Mistri
|
|
|
256
245
|
"delay" => pause.round(2))
|
|
257
246
|
note = format("attempt %<attempt>d failed; retrying in %<pause>.1fs",
|
|
258
247
|
attempt: attempt, pause: pause)
|
|
259
|
-
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),
|
|
260
250
|
message: message))
|
|
261
251
|
end
|
|
262
252
|
|
|
@@ -302,7 +292,8 @@ module Mistri
|
|
|
302
292
|
|
|
303
293
|
results = ToolExecutor.call(calls, @tools_by_name, signal: signal,
|
|
304
294
|
max_concurrency: @max_concurrency,
|
|
305
|
-
session: @session, emit: emit
|
|
295
|
+
session: @session, emit: emit,
|
|
296
|
+
app: @context)
|
|
306
297
|
context = hook_context(signal, emit)
|
|
307
298
|
results.each do |call, result, seconds|
|
|
308
299
|
result = rewrite(call, result, context) if @after_tool
|
|
@@ -337,7 +328,7 @@ module Mistri
|
|
|
337
328
|
end
|
|
338
329
|
|
|
339
330
|
def hook_context(signal, emit)
|
|
340
|
-
ToolContext.new(session: @session, signal: signal, emit: emit)
|
|
331
|
+
ToolContext.new(session: @session, signal: signal, emit: emit, app: @context)
|
|
341
332
|
end
|
|
342
333
|
|
|
343
334
|
# The tool message carries both channels; the :tool_result event exposes
|
data/lib/mistri/content.rb
CHANGED
|
@@ -50,6 +50,15 @@ module Mistri
|
|
|
50
50
|
# second copy of what can be a multi-megabyte payload.
|
|
51
51
|
def self.from_bytes(bytes, mime_type:) = new(data: [bytes.b].pack("m0").freeze, mime_type:)
|
|
52
52
|
|
|
53
|
+
# Browsers, canvases, and upload pipelines hand images around as
|
|
54
|
+
# data: URIs; accept them directly.
|
|
55
|
+
def self.from_data_uri(uri)
|
|
56
|
+
match = /\Adata:(?<mime>[^;,]+);base64,(?<data>.+)\z/m.match(uri.to_s)
|
|
57
|
+
raise ArgumentError, "not a base64 data: URI" unless match
|
|
58
|
+
|
|
59
|
+
new(data: match[:data].delete("\n").freeze, mime_type: match[:mime])
|
|
60
|
+
end
|
|
61
|
+
|
|
53
62
|
def initialize(data:, mime_type:)
|
|
54
63
|
super(data: Content.freeze_string(data), mime_type: Content.freeze_string(mime_type))
|
|
55
64
|
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Mistri
|
|
6
|
+
# An agent definition: a markdown file whose YAML frontmatter carries the
|
|
7
|
+
# config and whose body is the prompt. Prompts stay editable prose in
|
|
8
|
+
# files a reviewer can diff; code stays out of them.
|
|
9
|
+
#
|
|
10
|
+
# ---
|
|
11
|
+
# role: Trip Planner
|
|
12
|
+
# model: claude-opus-4-8
|
|
13
|
+
# tools:
|
|
14
|
+
# - search_flights
|
|
15
|
+
# - book_hotel
|
|
16
|
+
# ---
|
|
17
|
+
# You plan trips end to end. Address the traveler as {first_name}.
|
|
18
|
+
#
|
|
19
|
+
# definition = Mistri::Definition.load("app/agents/trip_planner.md")
|
|
20
|
+
# agent = Mistri.agent(definition.model,
|
|
21
|
+
# system: definition.render(first_name: traveler.first_name),
|
|
22
|
+
# tools: registry.build(definition.tools, traveler))
|
|
23
|
+
#
|
|
24
|
+
# The gem reads the file; what tool names mean, and what any extra
|
|
25
|
+
# frontmatter keys mean, stays the host's vocabulary via #config.
|
|
26
|
+
class Definition
|
|
27
|
+
FRONTMATTER = /\A---\s*\n(?<yaml>.*?)\n---\s*\n(?<body>.*)\z/m
|
|
28
|
+
|
|
29
|
+
attr_reader :name, :body, :config
|
|
30
|
+
|
|
31
|
+
def self.load(path)
|
|
32
|
+
match = FRONTMATTER.match(File.read(path))
|
|
33
|
+
raise ConfigurationError, "#{path} has no frontmatter" unless match
|
|
34
|
+
|
|
35
|
+
config = YAML.safe_load(match[:yaml]) || {}
|
|
36
|
+
raise ConfigurationError, "#{path} frontmatter is not a mapping" unless config.is_a?(Hash)
|
|
37
|
+
|
|
38
|
+
new(name: File.basename(path.to_s, ".md"), config: config, body: match[:body].strip)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(name:, config:, body:)
|
|
42
|
+
@name = name.to_s
|
|
43
|
+
@config = config.freeze
|
|
44
|
+
@body = body
|
|
45
|
+
freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def model = config["model"]
|
|
49
|
+
|
|
50
|
+
def role = config["role"]
|
|
51
|
+
|
|
52
|
+
# Tool declarations as a name => options map. A bare list means no
|
|
53
|
+
# options; a map form carries per-tool options in the host's
|
|
54
|
+
# vocabulary (gates, caching, whatever the host honors).
|
|
55
|
+
def tools
|
|
56
|
+
raw = config["tools"]
|
|
57
|
+
entries = case raw
|
|
58
|
+
when Hash then raw.transform_values { |options| options || {} }
|
|
59
|
+
when Array then raw.to_h { |tool| [tool, {}] }
|
|
60
|
+
else {}
|
|
61
|
+
end
|
|
62
|
+
entries.transform_keys(&:to_s)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def tool_names = tools.keys
|
|
66
|
+
|
|
67
|
+
# The body with {placeholders} filled in. An unfilled placeholder
|
|
68
|
+
# raises: a prompt silently addressing "{first_name}" is worse than an
|
|
69
|
+
# error. A value of nil renders as empty, which lets optional context
|
|
70
|
+
# collapse cleanly.
|
|
71
|
+
def render(vars = {})
|
|
72
|
+
vars = vars.transform_keys(&:to_s)
|
|
73
|
+
body.gsub(/\{(\w+)\}/) do
|
|
74
|
+
vars.fetch(Regexp.last_match(1)) do |key|
|
|
75
|
+
raise ConfigurationError, "no value for {#{key}} in the #{name} definition"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
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
|
|
|
@@ -25,7 +25,8 @@ module Mistri
|
|
|
25
25
|
case record["type"]
|
|
26
26
|
when "response.output_item.added" then start_item(record["item"], &)
|
|
27
27
|
when "response.output_text.delta" then text_delta(record["delta"], &)
|
|
28
|
-
when "response.reasoning_summary_text.delta"
|
|
28
|
+
when "response.reasoning_summary_text.delta"
|
|
29
|
+
thinking_delta(record["delta"], record["summary_index"], &)
|
|
29
30
|
when "response.function_call_arguments.delta" then arguments_delta(record["delta"], &)
|
|
30
31
|
when "response.output_item.done" then finish_item(record["item"], &)
|
|
31
32
|
when "response.completed", "response.incomplete", "response.failed"
|
|
@@ -83,6 +84,7 @@ module Mistri
|
|
|
83
84
|
return unless kind
|
|
84
85
|
|
|
85
86
|
@current = Builder.new(kind, @blocks.size, +"", +"")
|
|
87
|
+
@summary_part = nil
|
|
86
88
|
emit_event(:"#{kind}_start", content_index: @current.index, &)
|
|
87
89
|
end
|
|
88
90
|
|
|
@@ -93,9 +95,17 @@ module Mistri
|
|
|
93
95
|
emit_event(:text_delta, content_index: @current.index, delta: delta, &)
|
|
94
96
|
end
|
|
95
97
|
|
|
96
|
-
|
|
98
|
+
# A reasoning item carries one or more summary parts, each its own
|
|
99
|
+
# paragraph; the boundary streams as a delta so live views keep the
|
|
100
|
+
# break the finished text will have.
|
|
101
|
+
def thinking_delta(delta, part, &)
|
|
97
102
|
return unless @current
|
|
98
103
|
|
|
104
|
+
if @summary_part && part && part != @summary_part
|
|
105
|
+
@current.text << "\n\n"
|
|
106
|
+
emit_event(:thinking_delta, content_index: @current.index, delta: "\n\n", &)
|
|
107
|
+
end
|
|
108
|
+
@summary_part = part if part
|
|
99
109
|
@current.text << delta.to_s
|
|
100
110
|
emit_event(:thinking_delta, content_index: @current.index, delta: delta, &)
|
|
101
111
|
end
|
|
@@ -131,7 +141,7 @@ module Mistri
|
|
|
131
141
|
text = Array(item["content"]).filter_map { |part| part["text"] }.join
|
|
132
142
|
Content::Text.new(text: text, signature: item["id"])
|
|
133
143
|
when :thinking
|
|
134
|
-
summary = Array(item["summary"]).filter_map { |part| part["text"] }.join
|
|
144
|
+
summary = Array(item["summary"]).filter_map { |part| part["text"] }.join("\n\n")
|
|
135
145
|
Content::Thinking.new(thinking: summary, signature: JSON.generate(item))
|
|
136
146
|
when :toolcall
|
|
137
147
|
ToolCall.new(id: item["call_id"], name: item["name"],
|
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/tool_context.rb
CHANGED
|
@@ -2,13 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
module Mistri
|
|
4
4
|
# What a tool handler may know about the run it executes inside: the
|
|
5
|
-
# caller's session, the abort signal,
|
|
6
|
-
# it as an optional second argument — a
|
|
7
|
-
# lambda opts in by accepting two
|
|
8
|
-
# any tool that spawns work,
|
|
9
|
-
# progress can use it the same
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
# caller's session, the abort signal, the event stream, and the host's
|
|
6
|
+
# own context object. Handlers take it as an optional second argument — a
|
|
7
|
+
# proc ignores it invisibly, a lambda opts in by accepting two
|
|
8
|
+
# parameters. Sub-agents are built on it; any tool that spawns work,
|
|
9
|
+
# links records to the session, or streams progress can use it the same
|
|
10
|
+
# way.
|
|
11
|
+
#
|
|
12
|
+
# app carries whatever the host passes as Agent.new(context:) — the
|
|
13
|
+
# acting user, a tenant, a request — untouched. The gem provides the
|
|
14
|
+
# slot, never the vocabulary:
|
|
15
|
+
#
|
|
16
|
+
# agent = Mistri.agent("claude-opus-4-8", tools: tools,
|
|
17
|
+
# context: { traveler: current_traveler })
|
|
18
|
+
# Mistri::Tool.define("book_hotel", "Books the chosen hotel.") do |args, context|
|
|
19
|
+
# Bookings.create(args, traveler: context.app[:traveler])
|
|
20
|
+
# end
|
|
21
|
+
ToolContext = Data.define(:session, :signal, :emit, :app) do
|
|
22
|
+
def initialize(session: nil, signal: nil, emit: nil, app: nil)
|
|
12
23
|
super
|
|
13
24
|
end
|
|
14
25
|
end
|
data/lib/mistri/tool_executor.rb
CHANGED
|
@@ -16,10 +16,12 @@ module Mistri
|
|
|
16
16
|
|
|
17
17
|
module_function
|
|
18
18
|
|
|
19
|
-
def call(calls, tools_by_name, signal: nil, max_concurrency: 4, session: nil, emit: nil
|
|
19
|
+
def call(calls, tools_by_name, signal: nil, max_concurrency: 4, session: nil, emit: nil,
|
|
20
|
+
app: nil)
|
|
20
21
|
return [] if calls.empty?
|
|
21
22
|
|
|
22
|
-
context = ToolContext.new(session: session, signal: signal, emit: thread_safe(emit)
|
|
23
|
+
context = ToolContext.new(session: session, signal: signal, emit: thread_safe(emit),
|
|
24
|
+
app: app)
|
|
23
25
|
results = Array.new(calls.length)
|
|
24
26
|
queue = Queue.new
|
|
25
27
|
calls.each_with_index { |call, index| queue << [call, index] }
|
data/lib/mistri/version.rb
CHANGED
data/lib/mistri.rb
CHANGED
|
@@ -23,6 +23,7 @@ require_relative "mistri/workspace/directory"
|
|
|
23
23
|
require_relative "mistri/workspace/single"
|
|
24
24
|
require_relative "mistri/memory"
|
|
25
25
|
require_relative "mistri/tools"
|
|
26
|
+
require_relative "mistri/definition"
|
|
26
27
|
require_relative "mistri/skill"
|
|
27
28
|
require_relative "mistri/skills"
|
|
28
29
|
require_relative "mistri/tool_executor"
|
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
|
+
version: 0.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Muhammad Ahmed Cheema
|
|
@@ -34,6 +34,7 @@ files:
|
|
|
34
34
|
- lib/mistri/compaction.rb
|
|
35
35
|
- lib/mistri/compactor.rb
|
|
36
36
|
- lib/mistri/content.rb
|
|
37
|
+
- lib/mistri/definition.rb
|
|
37
38
|
- lib/mistri/edit.rb
|
|
38
39
|
- lib/mistri/errors.rb
|
|
39
40
|
- lib/mistri/event.rb
|