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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 05f65dbeaf12ad0dc59334e300a2ecd2febc01607d528c0d8e7a95003c49e608
4
- data.tar.gz: f1c261e05ee6e433f3b08f6c129841b6b25a44ee935671d3314681de792d0da5
3
+ metadata.gz: 820753fb8dd64f485da060568e6dbc8a7af3fa24d63a2cc7dc957b5dbfc9f1d6
4
+ data.tar.gz: c3e7432802801d19979c3d2906f700fbe3170a1d4950d22f0da88e01b276b905
5
5
  SHA512:
6
- metadata.gz: 852c0bc7b87c7aa8b09b6862b88babbdbe3ddd6bd3e9736d8429818a3daaa98279fbe4f7703fd45e371ab78d7e76b0660ee19c47f183c760bcdba41d58bb6929
7
- data.tar.gz: 2e97b19dfee548042488c16d4d37ddcdf5adf3959de7cd90d076e29c667021f2c07aec603eb8d6d0316ce944a027b55da9ca13f2adf3bc1647dac38b16316c0c
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://codecov.io/gh/mcheemaa/mistri/graph/badge.svg"></a>
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
- send_gift = Mistri::Tool.define("send_gift", "Sends a real gift.",
114
- needs_approval: ->(args) { args["amount"].to_i > 100 }) do |args|
115
- Gifts.send!(args)
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("Send Ana a $200 gift")
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, "session is awaiting approval decisions; call resume"
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, "run needs input text or images"
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 is recorded as a retry entry, never as a message, so retries
210
- # 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.
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, &emit)
221
+ output_schema: output_schema, &gate)
220
222
  attempt += 1
221
- error = turn_error(message)
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, reason: StopReason::ERROR,
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
@@ -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, 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
 
@@ -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" then thinking_delta(record["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
- def thinking_delta(delta, &)
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"],
@@ -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
@@ -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, and the event stream. Handlers take
6
- # it as an optional second argument — a proc ignores it invisibly, a
7
- # lambda opts in by accepting two parameters. Sub-agents are built on it;
8
- # any tool that spawns work, links records to the session, or streams
9
- # progress can use it the same way.
10
- ToolContext = Data.define(:session, :signal, :emit) do
11
- def initialize(session: nil, signal: nil, emit: nil)
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
@@ -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] }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mistri
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.1"
5
5
  end
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.3.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