mistri 0.2.1 → 0.4.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: de242bfa63b5f170166c93613fc2a2807971d45b792a6200eb60805c4f41c977
4
+ data.tar.gz: 362d523bba1848e2b552ece5906b1554c4a54820379c9f8e7a3e93430144a9c1
5
5
  SHA512:
6
- metadata.gz: cb690580fe35c2226bfa0d86d23f77c9236e8dcb10ba3af0a7753a1d52b96c11e951d41ee647d0cf947dae1d3c82ca834105070099dec0c4bac728bcd296698b
7
- data.tar.gz: 9e5be1a7fde00c351abc5f123ddfa82db8cb0ae34f14b0c6aa474b12338c587958c547d68e70280de7cda780ea4e674953e0f28681d33c17b37edd8ea1672d8e
6
+ metadata.gz: 2ac330d28d201397f03912c893a538e824c5889a999f7afff3dde4cca8fe8e3a1263f4b886623f6c65fa6b732eae02962e72d4d9c6e945215f2aa724af1d82cb
7
+ data.tar.gz: fa27dd203af02953b311a479992e82f87e526c8b825b6be057ec0c4bffc0a76172b19af11e57ed7c522a15d0f8e063724b74dd5c5173f11118e96736457c4453
data/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ 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.0] - 2026-07-05
9
+
10
+ - Mistri::Definition: agents as frontmatter markdown files. Config in
11
+ YAML, prompt in the body, {placeholders} filled at build time and
12
+ unfilled ones raise. Tool names and extra keys stay the host's
13
+ vocabulary.
14
+ - Agent context: Mistri.agent(context: anything) rides the run and reaches
15
+ every tool handler and hook as context.app, untouched.
16
+ - Content::Image.from_data_uri accepts base64 data: URIs directly.
17
+
18
+ - OpenAI reasoning summaries keep their paragraph structure: a reasoning
19
+ item's summary parts join with a blank line, and the boundary streams as
20
+ a thinking delta, so live views match the finished text.
21
+
22
+ ## [0.3.0] - 2026-07-05
23
+
24
+ - Sessions heal at replay: a run killed mid-tool (deploy, crash) leaves
25
+ tool calls without results, which providers reject on every later turn.
26
+ Unsettled calls now replay with a synthesized interrupted result; calls
27
+ parked for human approval stay open for resume. The stored log is never
28
+ rewritten, and context assembly now reads the store exactly once.
29
+ - Empty completions retry: a turn that answers with no text, thinking, or
30
+ tool calls (an intermittent provider behavior) now retries under the
31
+ standard policy instead of ending the run in silence.
32
+ - Gemini: consecutive user turns are no longer merged. Mixing a text part
33
+ into a functionResponse turn makes Gemini answer an empty candidate, so
34
+ steers and resumed prompts stay their own turns; Gemini accepts this.
35
+ - The spawn tool takes an optional `name`: the model labels each worker,
36
+ and the label rides origin tags and the transcript link, so fan-out
37
+ streams read as `pricing-scout#a41f` instead of `spawn#a41f`.
38
+ - The spawn tool's `model` parameter names the default child model in its
39
+ description, so the model's choice (or non-choice) is informed.
40
+
8
41
  ## [0.2.1] - 2026-07-05
9
42
 
10
43
  - The gem homepage and documentation links now point at
data/README.md CHANGED
@@ -7,10 +7,12 @@
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>
13
- <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>
14
16
  <a href="mistri.gemspec"><img alt="Ruby >= 3.2" src="https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D"></a>
15
17
  <a href="Gemfile"><img alt="Runtime dependencies: zero" src="https://img.shields.io/badge/runtime_deps-0-brightgreen"></a>
16
18
  <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg"></a>
@@ -100,6 +102,20 @@ Mistri::Tool.define("edit_page", "Applies a page edit.") do |args|
100
102
  end
101
103
  ```
102
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
+
103
119
  ## Human approval
104
120
 
105
121
  Mark a tool `needs_approval: true` (or a predicate on its arguments) and the
@@ -108,12 +124,12 @@ The decision is a one-line session write from any process, any time later;
108
124
  `resume` settles it and carries on.
109
125
 
110
126
  ```ruby
111
- send_gift = Mistri::Tool.define("send_gift", "Sends a real gift.",
112
- needs_approval: ->(args) { args["amount"].to_i > 100 }) do |args|
113
- 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)
114
130
  end
115
131
 
116
- result = agent.run("Send Ana a $200 gift")
132
+ result = agent.run("Book the corner suite for the Lisbon trip")
117
133
  result.awaiting_approval? # => true; nothing executed
118
134
 
119
135
  # Days later, in a controller:
@@ -213,6 +229,20 @@ A skill is a `SKILL.md` (or flat `.md`) with `name:`/`description:`
213
229
  frontmatter, or built from database rows with
214
230
  `Mistri::Skill.new(name:, description:, body:)`.
215
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
+
216
246
  ## Sub-agents
217
247
 
218
248
  Delegate to a child agent with a clean context: exploration fills the
@@ -230,8 +260,9 @@ agent = Mistri.agent("claude-opus-4-8", tools: [researcher.tool])
230
260
  ```
231
261
 
232
262
  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:
263
+ a name for the event stream, instructions, a tool subset, and a
264
+ host-allowlisted model per child. Several spawns in one turn fan out in
265
+ parallel:
235
266
 
236
267
  ```ruby
237
268
  spawn = Mistri::SubAgent.spawner(provider: provider, tools: [fetch_page, search])
@@ -342,6 +373,7 @@ policy = Mistri::RetryPolicy.new(attempts: 3)
342
373
 
343
374
  ```ruby
344
375
  photo = Mistri::Content::Image.from_bytes(File.binread("chart.png"), mime_type: "image/png")
376
+ photo = Mistri::Content::Image.from_data_uri(params[:image]) # canvases and uploads
345
377
  agent.run("What trend does this chart show?", images: [photo])
346
378
 
347
379
  Mistri.agent("gpt-5.5", provider_options: { reasoning: { effort: "high" } })
@@ -351,18 +383,21 @@ Mistri.agent("claude-opus-4-8", provider_options: { cache: false })
351
383
  ## Testing
352
384
 
353
385
  `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.
386
+ to end against real provider APIs, once per model in the matrix: an
387
+ Anthropic, an OpenAI, and a Gemini model by default. Scenarios assert that
388
+ coined codenames (a ghost of a word like `Wraithowyn` exists in no training
389
+ data) flowed through tool results, summaries, and child agents: proof of
390
+ information flow, not model knowledge.
358
391
 
359
392
  ```console
360
- $ MISTRI_INTEGRATION_MODELS=claude-opus-4-8,gpt-5.5 bundle exec rake integration
393
+ $ bundle exec rake integration
394
+ $ MISTRI_INTEGRATION_MODELS=claude-opus-4-8 bundle exec rake integration
361
395
  ```
362
396
 
363
397
  ## Roadmap
364
398
 
365
- `0.2.0`: an MCP client bridge, so any MCP server's tools plug into an agent.
399
+ Next up: strict tool schemas, provider-native MCP passthrough, and the
400
+ hardening that falls out of the first production applications.
366
401
 
367
402
  ## Credits
368
403
 
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,10 +47,17 @@ 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
 
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
+
53
61
  # Run one exchange: append the user turn, then loop until the model
54
62
  # answers without tools, a gated tool suspends the run, the run aborts,
55
63
  # or a budget stops it.
@@ -212,9 +220,10 @@ module Mistri
212
220
  tools: @tools.map(&:spec), signal: signal,
213
221
  output_schema: output_schema, &emit)
214
222
  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)
223
+ error = turn_error(message)
224
+ if retry_turn?(error, attempt, signal)
225
+ pause = @retries.delay(attempt, error["retry_after"])
226
+ record_retry(message, error, attempt, pause, &emit)
218
227
  wait(pause, signal)
219
228
  next unless signal&.aborted?
220
229
  end
@@ -223,15 +232,29 @@ module Mistri
223
232
  end
224
233
  end
225
234
 
226
- def retry_turn?(message, attempt, signal)
227
- return false unless @retries && message.stop_reason == StopReason::ERROR
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
+ def retry_turn?(error, attempt, signal)
250
+ return false unless @retries && error
228
251
  return false if signal&.aborted?
229
252
 
230
- @retries.retry?(message.error, attempt)
253
+ @retries.retry?(error, attempt)
231
254
  end
232
255
 
233
- def record_retry(message, attempt, pause, &emit)
234
- @session.append("retry", "attempt" => attempt, "error" => message.error,
256
+ def record_retry(message, error, attempt, pause, &emit)
257
+ @session.append("retry", "attempt" => attempt, "error" => error,
235
258
  "delay" => pause.round(2))
236
259
  note = format("attempt %<attempt>d failed; retrying in %<pause>.1fs",
237
260
  attempt: attempt, pause: pause)
@@ -281,7 +304,8 @@ module Mistri
281
304
 
282
305
  results = ToolExecutor.call(calls, @tools_by_name, signal: signal,
283
306
  max_concurrency: @max_concurrency,
284
- session: @session, emit: emit)
307
+ session: @session, emit: emit,
308
+ app: @context)
285
309
  context = hook_context(signal, emit)
286
310
  results.each do |call, result, seconds|
287
311
  result = rewrite(call, result, context) if @after_tool
@@ -316,7 +340,7 @@ module Mistri
316
340
  end
317
341
 
318
342
  def hook_context(signal, emit)
319
- ToolContext.new(session: @session, signal: signal, emit: emit)
343
+ ToolContext.new(session: @session, signal: signal, emit: emit, app: @context)
320
344
  end
321
345
 
322
346
  # 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
@@ -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)
@@ -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"],
@@ -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)
@@ -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.2.1"
4
+ VERSION = "0.4.0"
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.2.1
4
+ version: 0.4.0
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