mistri 0.5.0 → 0.6.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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +469 -4
  3. data/CONTRIBUTING.md +52 -0
  4. data/README.md +289 -385
  5. data/SECURITY.md +40 -0
  6. data/UPGRADING.md +640 -0
  7. data/assets/logo-animated.svg +30 -0
  8. data/assets/logo-dark.svg +14 -0
  9. data/assets/logo-light.svg +14 -0
  10. data/assets/logo.svg +14 -0
  11. data/assets/social-preview.png +0 -0
  12. data/docs/README.md +87 -0
  13. data/docs/context-and-workspaces.md +378 -0
  14. data/docs/mcp.md +366 -0
  15. data/docs/reliability.md +450 -0
  16. data/docs/sessions.md +295 -0
  17. data/docs/sub-agents.md +401 -0
  18. data/docs/tool-contracts.md +324 -0
  19. data/examples/approval.rb +36 -0
  20. data/examples/browser.rb +27 -0
  21. data/examples/page_editor.rb +31 -0
  22. data/examples/quickstart.rb +21 -0
  23. data/lib/generators/mistri/install/install_generator.rb +7 -3
  24. data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
  25. data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
  26. data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
  27. data/lib/mistri/agent.rb +575 -55
  28. data/lib/mistri/budget.rb +26 -1
  29. data/lib/mistri/child.rb +72 -16
  30. data/lib/mistri/compaction.rb +26 -10
  31. data/lib/mistri/compactor.rb +34 -11
  32. data/lib/mistri/console.rb +28 -7
  33. data/lib/mistri/content.rb +9 -3
  34. data/lib/mistri/dispatchers.rb +14 -12
  35. data/lib/mistri/errors.rb +83 -4
  36. data/lib/mistri/event.rb +24 -8
  37. data/lib/mistri/event_delivery.rb +60 -0
  38. data/lib/mistri/locks.rb +3 -3
  39. data/lib/mistri/mcp/client.rb +74 -19
  40. data/lib/mistri/mcp/egress.rb +216 -0
  41. data/lib/mistri/mcp/oauth.rb +476 -127
  42. data/lib/mistri/mcp/wires.rb +115 -23
  43. data/lib/mistri/mcp.rb +42 -8
  44. data/lib/mistri/message.rb +21 -11
  45. data/lib/mistri/models.rb +160 -22
  46. data/lib/mistri/providers/anthropic/assembler.rb +282 -44
  47. data/lib/mistri/providers/anthropic/serializer.rb +14 -9
  48. data/lib/mistri/providers/anthropic.rb +29 -6
  49. data/lib/mistri/providers/fake.rb +26 -10
  50. data/lib/mistri/providers/gemini/assembler.rb +148 -21
  51. data/lib/mistri/providers/gemini/serializer.rb +78 -9
  52. data/lib/mistri/providers/gemini.rb +31 -5
  53. data/lib/mistri/providers/openai/assembler.rb +337 -60
  54. data/lib/mistri/providers/openai/serializer.rb +13 -12
  55. data/lib/mistri/providers/openai.rb +29 -5
  56. data/lib/mistri/providers/schema_capabilities.rb +214 -0
  57. data/lib/mistri/result.rb +1 -1
  58. data/lib/mistri/schema.rb +893 -75
  59. data/lib/mistri/session.rb +560 -48
  60. data/lib/mistri/sinks/coalesced.rb +17 -10
  61. data/lib/mistri/spawner.rb +111 -61
  62. data/lib/mistri/sse.rb +57 -14
  63. data/lib/mistri/stores/active_record.rb +1 -1
  64. data/lib/mistri/stores/memory.rb +21 -2
  65. data/lib/mistri/sub_agent/execution.rb +81 -0
  66. data/lib/mistri/sub_agent/runtime.rb +297 -0
  67. data/lib/mistri/sub_agent.rb +124 -87
  68. data/lib/mistri/task_output.rb +24 -6
  69. data/lib/mistri/tool.rb +93 -13
  70. data/lib/mistri/tool_arguments.rb +377 -0
  71. data/lib/mistri/tool_call.rb +43 -9
  72. data/lib/mistri/tool_context.rb +4 -2
  73. data/lib/mistri/tool_executor.rb +117 -26
  74. data/lib/mistri/tool_result.rb +15 -10
  75. data/lib/mistri/tools/edit_file.rb +62 -8
  76. data/lib/mistri/tools.rb +41 -4
  77. data/lib/mistri/transport.rb +149 -44
  78. data/lib/mistri/usage.rb +65 -13
  79. data/lib/mistri/version.rb +1 -1
  80. data/lib/mistri/workspace/active_record.rb +183 -3
  81. data/lib/mistri/workspace/directory.rb +28 -8
  82. data/lib/mistri/workspace/memory.rb +34 -9
  83. data/lib/mistri/workspace/single.rb +62 -5
  84. data/lib/mistri/workspace.rb +39 -0
  85. data/lib/mistri.rb +6 -1
  86. data/mistri.gemspec +34 -0
  87. metadata +31 -3
data/lib/mistri/budget.rb CHANGED
@@ -16,11 +16,36 @@ module Mistri
16
16
 
17
17
  def none? = [@turns, @tokens, @cost_usd, @wall_clock].all?(&:nil?)
18
18
 
19
+ def cost? = !@cost_usd.nil?
20
+
21
+ def validate_provider!(provider)
22
+ return unless cost?
23
+ return if provider.respond_to?(:prices_usage?) && provider.prices_usage?
24
+
25
+ raise ConfigurationError,
26
+ "cost budget requires a catalogued model, list-priced origin, and deterministic " \
27
+ "standard service tier for #{provider.model.inspect}"
28
+ end
29
+
30
+ def validate_usage!(usage)
31
+ return unless cost?
32
+ return if usage.cost.known?
33
+
34
+ raise BudgetError.new(
35
+ "cost budget cannot continue after a request returned unpriced usage; not retrying",
36
+ usage:
37
+ )
38
+ end
39
+
19
40
  # The reason the run should stop, or nil to continue.
20
41
  def exceeded(turns:, usage:, elapsed: 0)
21
42
  return :turns if @turns && turns >= @turns
22
43
  return :tokens if @tokens && usage.total_tokens >= @tokens
23
- return :cost if @cost_usd && usage.cost.total >= @cost_usd
44
+
45
+ if @cost_usd
46
+ validate_usage!(usage)
47
+ return :cost if usage.cost.total >= @cost_usd
48
+ end
24
49
  return :wall_clock if @wall_clock && elapsed >= @wall_clock
25
50
 
26
51
  nil
data/lib/mistri/child.rb CHANGED
@@ -6,7 +6,7 @@ module Mistri
6
6
  # reads the same from any process, while the child runs and forever after.
7
7
  #
8
8
  # session.children # => [#<Mistri::Child Magpie done>, ...]
9
- # child.status # => :running, :done, :stopped, :failed
9
+ # child.status # queued, running, interrupted, or terminal
10
10
  # child.report # the terminal entry's report, once finished
11
11
  # child.transcript(tail: 20) # recent entries, image bytes stripped
12
12
  # child.say("Also check their pricing page")
@@ -20,8 +20,8 @@ module Mistri
20
20
  TERMINAL = "subagent_result"
21
21
  DISPATCHED = "subagent_dispatched"
22
22
  STARTED = "subagent_started"
23
- # The states a worker can still be caught in: steerable, stoppable,
24
- # worth waiting on.
23
+ # Work currently scheduled or queued, used for capacity and steering.
24
+ # Interrupted work is nonterminal but not actively live.
25
25
  LIVE = %i[running queued].freeze
26
26
 
27
27
  attr_reader :name, :session_id
@@ -30,10 +30,11 @@ module Mistri
30
30
 
31
31
  def self.stop_key(session_id) = "child-stop:#{session_id}"
32
32
 
33
- def initialize(name:, session_id:, store:)
33
+ def initialize(name:, session_id:, store:, parent_session_id: nil)
34
34
  @name = name
35
35
  @session_id = session_id
36
36
  @store = store
37
+ @parent_session_id = parent_session_id
37
38
  end
38
39
 
39
40
  def status
@@ -49,10 +50,9 @@ module Mistri
49
50
  :running
50
51
  end
51
52
 
52
- # A terminal entry exists: the child ended as done, stopped, or failed
53
- # and will never run again. The question a queue retry asks; its
54
- # inverse (started but no terminal) is what makes a crashed child
55
- # re-runnable.
53
+ # A terminal entry exists: the child ended as done, stopped, or failed,
54
+ # so a later matching delivery must not run it again. The inverse
55
+ # (started but no terminal) is what makes a crashed child re-runnable.
56
56
  def finished?
57
57
  !terminal_entry.nil?
58
58
  end
@@ -82,17 +82,17 @@ module Mistri
82
82
  session.steer(text)
83
83
  end
84
84
 
85
- # Ask a live child to stop, from any process. A running child's runner
86
- # sees the flag within a tick and trips its own signal; a queued child
87
- # is cancelled outright with a stopped terminal, which the runner
88
- # honors by never starting it. Stop is cross-process by nature, so it
89
- # needs a lock adapter; without one this returns false. An action that
90
- # reports acceptance, not a predicate.
85
+ # Ask a child to stop, from any process. An inactive dispatched child is
86
+ # cancelled durably under its lease; an inline or already-running child
87
+ # receives the stop flag. Repeating stop on a stopped dispatched child
88
+ # reconciles a missing parent report. Stop is
89
+ # cross-process by nature, so it needs a lock adapter; without one this
90
+ # returns false. An action that reports acceptance, not a predicate.
91
91
  def stop # rubocop:disable Naming/PredicateMethod
92
92
  return false unless Mistri.locks
93
93
 
94
- session.append(TERMINAL, "status" => "stopped") if status == :queued
95
- Mistri.locks.set_flag(self.class.stop_key(@session_id))
94
+ outcome = cancel_inactive
95
+ Mistri.locks.set_flag(self.class.stop_key(@session_id)) if outcome == :contended
96
96
  true
97
97
  end
98
98
 
@@ -119,6 +119,62 @@ module Mistri
119
119
 
120
120
  private
121
121
 
122
+ # Cancellation and a runner compete for the same lease. Acquiring it
123
+ # proves no unexpired lease is present, so ordinary inactive dispatched
124
+ # work can end here; a stale holder may still resume under the documented
125
+ # tokenless lease trade-off. Status cannot be rechecked because our own
126
+ # lease would read running.
127
+ def cancel_inactive
128
+ key = self.class.lease_key(@session_id)
129
+ lease = Locks.hold(key)
130
+ return :contended unless lease
131
+
132
+ begin
133
+ log = session.entries
134
+ terminal = log.reverse_each.find { |entry| entry["type"] == TERMINAL }
135
+ dispatched = log.any? { |entry| entry["type"] == DISPATCHED }
136
+ if terminal
137
+ deliver_cancellation if terminal["status"] == "stopped"
138
+ :finished
139
+ elsif dispatched
140
+ session.append(TERMINAL, "status" => "stopped")
141
+ deliver_cancellation
142
+ :cancelled
143
+ else
144
+ Mistri.locks.set_flag(self.class.stop_key(@session_id))
145
+ :signaled
146
+ end
147
+ ensure
148
+ lease.release
149
+ end
150
+ end
151
+
152
+ # A cancelled queue item may never be delivered, so the lease owner
153
+ # must route its durable outcome. Current versions trust only the stored
154
+ # grant; legacy children fall back to the parent link because their
155
+ # dispatched entry did not retain a spec.
156
+ def deliver_cancellation
157
+ dispatched = session.entries.find { |entry| entry["type"] == DISPATCHED }
158
+ return unless dispatched
159
+
160
+ spec = dispatched["spec"]
161
+ parent_id, label = cancellation_route(spec)
162
+ return unless parent_id.is_a?(String) && !parent_id.empty?
163
+ return unless label.is_a?(String) && !label.empty?
164
+
165
+ Session.new(store: @store, id: parent_id)
166
+ .deliver_report(name: label, session_id: @session_id, status: "stopped")
167
+ end
168
+
169
+ def cancellation_route(spec)
170
+ return [@parent_session_id, @name] if spec.nil?
171
+ return [nil, nil] unless spec.is_a?(Hash)
172
+ return [nil, nil] unless spec["spec_version"] == SubAgent::DISPATCH_SPEC_VERSION
173
+ return [nil, nil] unless spec["session_id"] == @session_id
174
+
175
+ [spec["parent_session_id"], spec["name"]]
176
+ end
177
+
122
178
  def session
123
179
  @session ||= Session.new(store: @store, id: @session_id)
124
180
  end
@@ -14,6 +14,7 @@ module Mistri
14
14
  class Compaction
15
15
  DEFAULT_RESERVE = 16_384
16
16
  DEFAULT_KEEP_RECENT = 20_000
17
+ OUTPUT_SAFETY = 4_096
17
18
  IMAGE_CHARS = 4_800
18
19
 
19
20
  SUMMARY_PREFACE = "The earlier conversation was compacted. This summary replaces it:"
@@ -21,28 +22,43 @@ module Mistri
21
22
  attr_reader :reserve, :keep_recent, :window, :instructions
22
23
 
23
24
  # window overrides the model catalog's context window (required for
24
- # models the catalog does not know). instructions add a host-specific
25
- # focus to the summary prompt.
26
- def initialize(reserve: DEFAULT_RESERVE, keep_recent: DEFAULT_KEEP_RECENT,
25
+ # models the catalog does not know). A nil reserve selects automatic
26
+ # headroom; a number is explicit host policy. instructions add a
27
+ # host-specific focus to the summary prompt.
28
+ def initialize(reserve: nil, keep_recent: DEFAULT_KEEP_RECENT,
27
29
  window: nil, instructions: nil)
28
- @reserve = reserve
30
+ @automatic_reserve = reserve.nil?
31
+ @reserve = reserve || DEFAULT_RESERVE
29
32
  @keep_recent = keep_recent
30
33
  @window = window
31
34
  @instructions = instructions
32
35
  end
33
36
 
34
- # Compact when the context has grown into the reserve headroom. An
35
- # unknown window never triggers.
36
- def needed?(tokens, window)
37
- window ? tokens > window - reserve : false
37
+ def automatic_reserve? = @automatic_reserve
38
+
39
+ # Automatic headroom fits output that shares the context window, plus
40
+ # estimation and framing slack. An explicit reserve remains host policy.
41
+ # An unknown window never triggers.
42
+ def needed?(tokens, window, max_output: nil)
43
+ window ? tokens > window - effective_reserve(max_output) : false
44
+ end
45
+
46
+ private
47
+
48
+ def effective_reserve(max_output)
49
+ return reserve unless automatic_reserve?
50
+
51
+ max_output ? [max_output + OUTPUT_SAFETY, reserve].max : reserve
38
52
  end
39
53
 
40
54
  class << self
41
55
  # Context size for a replay: the last healthy turn's reported tokens
42
56
  # (prompt, cache, and output all sit in context next turn) plus an
43
57
  # estimate of every message after it.
44
- def context_tokens(messages)
45
- index = messages.rindex { |message| reported(message) }
58
+ def context_tokens(messages, usage_from: 0)
59
+ index = (usage_from...messages.length).reverse_each.find do |position|
60
+ reported(messages[position])
61
+ end
46
62
  base = index ? reported(messages[index]) : 0
47
63
  messages.drop(index ? index + 1 : 0).sum(base) { |message| estimate(message) }
48
64
  end
@@ -9,10 +9,12 @@ module Mistri
9
9
  # transcript UIs; only what the model sees shrinks. Callable from any
10
10
  # process (a UI button, a job), with or without a running agent.
11
11
  #
12
- # Cuts land only on user messages, so a tool call and its result always
13
- # stay on the same side, and a parked approval's turn is never cut away
14
- # from the resume that must answer it.
12
+ # Cuts land on user messages or assistant tool-call turns, never results,
13
+ # so every call/result set stays on one side. A parked approval's turn is
14
+ # never cut away from the resume that must answer it.
15
15
  class Compactor
16
+ TOOL_RESULT_MAX_CHARS = 2_000
17
+
16
18
  SUMMARIZER_SYSTEM = <<~PROMPT
17
19
  You are a context summarization assistant. Read the conversation and
18
20
  produce only the structured summary you are asked for. Do not continue
@@ -43,8 +45,8 @@ module Mistri
43
45
  ## Critical Context
44
46
  - [Data, names, or references needed to continue, or "(none)"]
45
47
 
46
- Keep each section concise. Preserve exact identifiers, names, and error
47
- messages.
48
+ Keep each section concise. Preserve exact identifiers, names, paths,
49
+ URLs, commands, numbers, and error messages.
48
50
  FORMAT
49
51
 
50
52
  CHECKPOINT_PROMPT = <<~PROMPT.freeze
@@ -82,7 +84,7 @@ module Mistri
82
84
  return nil if head.empty?
83
85
 
84
86
  emit&.call(Event.new(type: :compacting))
85
- tokens_before = Compaction.context_tokens(replay.map(&:first))
87
+ tokens_before = session.context_tokens
86
88
  reply = summarize(provider, head, previous, settings.instructions)
87
89
  session.append("compaction", "summary" => reply.text,
88
90
  "kept_from" => cut, "tokens_before" => tokens_before)
@@ -95,7 +97,9 @@ module Mistri
95
97
  boundary = keep_boundary(replay, settings.keep_recent)
96
98
  return nil unless boundary
97
99
 
98
- candidates = replay.filter_map { |(message, index)| index if index && message.user? }
100
+ candidates = replay.filter_map do |message, index|
101
+ index if index && cut_candidate?(message)
102
+ end
99
103
  cut = candidates.find { |index| index >= boundary } || candidates.last
100
104
  cut = clamp_to_open_approvals(cut, session)
101
105
  return nil unless cut
@@ -104,8 +108,12 @@ module Mistri
104
108
  first && cut > first ? cut : nil
105
109
  end
106
110
 
111
+ def cut_candidate?(message)
112
+ message.user? || (message.assistant? && message.tool_calls?)
113
+ end
114
+
107
115
  # Walk back from the tail until the keep budget is spent; the cut then
108
- # snaps forward to a user message, so replay keeps at most about
116
+ # snaps to the next safe turn boundary, so replay keeps at most about
109
117
  # keep_recent tokens of recent turns.
110
118
  def keep_boundary(replay, keep_recent)
111
119
  kept = 0
@@ -145,7 +153,10 @@ module Mistri
145
153
  prompt << (previous ? UPDATE_PROMPT : CHECKPOINT_PROMPT)
146
154
  prompt << "\nAdditional focus: #{instructions}\n" if instructions
147
155
  reply = provider.stream(messages: [Message.user(prompt)], system: SUMMARIZER_SYSTEM)
148
- raise CompactionError, "summarization failed: #{reply.error_message}" unless usable?(reply)
156
+ unless usable?(reply)
157
+ raise CompactionError.new("summarization failed: #{reply.error_message}",
158
+ usage: reply.usage)
159
+ end
149
160
 
150
161
  reply
151
162
  end
@@ -155,7 +166,7 @@ module Mistri
155
166
  end
156
167
 
157
168
  def finish(session, reply, tokens_before, &emit)
158
- tokens_after = Compaction.context_tokens(session.messages)
169
+ tokens_after = session.context_tokens
159
170
  emit&.call(Event.new(type: :compaction, content: reply.text))
160
171
  { summary: reply.text, tokens_before: tokens_before,
161
172
  tokens_after: tokens_after, usage: reply.usage }
@@ -169,13 +180,25 @@ module Mistri
169
180
  end
170
181
 
171
182
  def text_of(message)
172
- message.content.filter_map do |block|
183
+ text = message.content.filter_map do |block|
173
184
  case block
174
185
  when Content::Text then block.text
175
186
  when Content::Image then "[image]"
176
187
  when ToolCall then "[called #{block.name} with #{JSON.generate(block.arguments)}]"
177
188
  end
178
189
  end.join("\n")
190
+ message.tool? ? truncate_tool_result(text) : text
191
+ end
192
+
193
+ # Large tool output remains durable and unchanged on ordinary model
194
+ # requests; only the lossy summary wire is bounded, with both ends kept.
195
+ def truncate_tool_result(text)
196
+ return text if text.length <= TOOL_RESULT_MAX_CHARS
197
+
198
+ marker = "\n[tool result truncated; original length: #{text.length} characters]\n"
199
+ visible = TOOL_RESULT_MAX_CHARS - marker.length
200
+ head = (visible * 0.75).floor
201
+ "#{text[0, head]}#{marker}#{text[-(visible - head), visible - head]}"
179
202
  end
180
203
  end
181
204
  end
@@ -98,15 +98,24 @@ module Mistri
98
98
  next Console.unknown(context.session, args["agent"]) unless child
99
99
 
100
100
  status = child.status
101
- if !Child::LIVE.include?(status)
101
+ if status == :stopped
102
+ child.stop
103
+ "#{child.name} is already stopped."
104
+ elsif !Child::LIVE.include?(status) && status != :interrupted
102
105
  "#{child.name} is already #{status}."
103
106
  elsif !child.stop
104
107
  "Stopping needs a lock adapter (Mistri.locks) and none is configured."
105
- elsif status == :queued
106
- "Cancelled. #{child.name} had not started and never will."
107
108
  else
108
- "Stop requested. #{child.name} halts within a second or two; " \
109
- "its partial work stays readable through read_agent."
109
+ stopped = child.status
110
+ if stopped == :stopped
111
+ "Cancelled. #{child.name} is marked stopped; ordinary queue delivery " \
112
+ "will not run it again."
113
+ elsif %i[done failed].include?(stopped)
114
+ "#{child.name} finished as #{stopped} before the stop took effect."
115
+ else
116
+ "Stop requested. #{child.name}'s runner receives the request promptly " \
117
+ "and stops at a cooperative boundary; partial work stays readable."
118
+ end
110
119
  end
111
120
  end
112
121
  end
@@ -133,7 +142,8 @@ module Mistri
133
142
  # promptly, never held hostage to the timeout.
134
143
  def await(child, timeout, poll, signal = nil)
135
144
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
136
- while Child::LIVE.include?(status = child.status)
145
+ until child.finished?
146
+ status = child.status
137
147
  return "The wait was stopped; #{child.name} is still #{status}." if signal&.aborted?
138
148
 
139
149
  if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
@@ -172,13 +182,24 @@ module Mistri
172
182
  calls = blocks.filter_map do |block|
173
183
  next unless block.is_a?(Hash) && block["type"] == "tool_call"
174
184
 
175
- "#{block["name"]}(#{JSON.generate(block["arguments"] || {})[0, 80]})"
185
+ "#{block["name"]}(#{Console.render_arguments(block)})"
176
186
  end
177
187
  text = Console.text_of(message["content"])
178
188
  parts = [text[0, 240], *calls].reject { |part| part.to_s.empty? }
179
189
  "#{message["role"]}: #{parts.join(" | ")}" unless parts.empty?
180
190
  end
181
191
 
192
+ def render_arguments(block)
193
+ if block["arguments_error"]
194
+ details = { "arguments_error" => block["arguments_error"] }
195
+ details = { "arguments" => block["arguments"] }.merge(details) if block.key?("arguments")
196
+ return JSON.generate(details)[0, 80]
197
+ end
198
+
199
+ arguments = block.key?("arguments") ? block["arguments"] : {}
200
+ JSON.generate(arguments)[0, 80]
201
+ end
202
+
182
203
  def text_of(content)
183
204
  return content.to_s unless content.is_a?(Array)
184
205
 
@@ -17,7 +17,10 @@ module Mistri
17
17
  # `signature` carries opaque provider metadata that must round-trip, such as
18
18
  # the OpenAI Responses message id and output phase.
19
19
  Text = Data.define(:text, :signature) do
20
- def initialize(text:, signature: nil) = super(text: Content.freeze_string(text), signature:)
20
+ def initialize(text:, signature: nil)
21
+ signature = Content.freeze_string(signature) if signature.is_a?(String)
22
+ super(text: Content.freeze_string(text), signature:)
23
+ end
21
24
 
22
25
  def type = :text
23
26
 
@@ -29,6 +32,7 @@ module Mistri
29
32
  # filter hid, leaving only the signature.
30
33
  Thinking = Data.define(:thinking, :signature, :redacted) do
31
34
  def initialize(thinking:, signature: nil, redacted: false)
35
+ signature = Content.freeze_string(signature) if signature.is_a?(String)
32
36
  super(thinking: Content.freeze_string(thinking), signature:, redacted:)
33
37
  end
34
38
 
@@ -89,8 +93,10 @@ module Mistri
89
93
  redacted: h.fetch("redacted", false))
90
94
  when "image" then Image.new(data: h["data"], mime_type: h["mime_type"])
91
95
  when "tool_call"
92
- ToolCall.new(id: h["id"], name: h["name"], arguments: h["arguments"] || {},
93
- signature: h["signature"])
96
+ arguments = h.key?("arguments") ? h["arguments"] : {}
97
+ ToolCall.new(id: h["id"], name: h["name"], arguments:,
98
+ signature: h["signature"], arguments_error: h["arguments_error"],
99
+ provider_call_id: h["provider_call_id"])
94
100
  else raise ArgumentError, "unknown content block type #{h["type"].inspect}"
95
101
  end
96
102
  end
@@ -2,10 +2,9 @@
2
2
 
3
3
  module Mistri
4
4
  # How a background child actually executes. The spawn tool hands every
5
- # dispatcher two things: the serializable spec (name, session_id,
6
- # parent_session_id, type, instructions, tool_names, model, workspace,
7
- # task) and a runner closure that executes the child in this process with
8
- # everything already reconstructed.
5
+ # dispatcher two things: a versioned serializable spec (name, session_id,
6
+ # parent_session_id, type, instructions, tool_names, model, task) and a
7
+ # runner closure that invokes the host runtime factory inside this process.
9
8
  #
10
9
  # In-process dispatchers just call the runner. A queue dispatcher ignores
11
10
  # the runner, enqueues the spec, and its job reconstructs the pieces from
@@ -13,10 +12,12 @@ module Mistri
13
12
  #
14
13
  # dispatcher: ->(spec, _runner) { ChildRunJob.perform_async(spec) }
15
14
  #
16
- # The runner closes over the spawn-time event sink, so in-process
17
- # children keep streaming to whoever watched the spawn; that sink must
18
- # outlive the parent's turn (a broadcast lambda does, a request-scoped
19
- # object may not).
15
+ # The runner invokes the host factory inside the worker and keeps the
16
+ # spawn-time event sink. In-process children stream to whoever watched the
17
+ # spawn; that sink must outlive the parent's turn (a broadcast lambda does,
18
+ # a request-scoped object may not) and hears from the worker's thread,
19
+ # concurrently with the parent's own events. The gem's sinks tolerate that;
20
+ # a custom one must too.
20
21
  module Dispatchers
21
22
  # Runs the child synchronously inside the spawn call and still answers
22
23
  # in receipt form: background degrades gracefully where no concurrency
@@ -34,11 +35,12 @@ module Mistri
34
35
  ::Thread.new do
35
36
  runner.call
36
37
  rescue StandardError => e
37
- # The runner writes the child's failed terminal before re-raising;
38
- # a thread must not die loudly into the process, but it must not
39
- # die silently either, or there is no trace to debug from.
38
+ reported = EventDelivery.original(e)
39
+ # Execution failures have a durable terminal before re-raising;
40
+ # cleanup may raise after a successful terminal. Neither should
41
+ # make a thread die loudly or disappear without a diagnostic.
40
42
  warn "mistri: background runner for #{spec["name"].inspect} crashed: " \
41
- "#{e.class}: #{e.message}"
43
+ "#{reported.class}: #{reported.message}"
42
44
  end
43
45
  nil
44
46
  end
data/lib/mistri/errors.rb CHANGED
@@ -11,6 +11,10 @@ module Mistri
11
11
  # Missing or contradictory setup: an unknown model, an absent API key.
12
12
  class ConfigurationError < Error; end
13
13
 
14
+ # A queue payload is not the grant persisted for its child. The unchanged
15
+ # delivery is poison: discard and alert instead of retrying it.
16
+ class DispatchGrantError < ConfigurationError; end
17
+
14
18
  # A provider request failed. Carries the HTTP status and response body when
15
19
  # the transport got that far.
16
20
  class ProviderError < Error
@@ -57,20 +61,89 @@ module Mistri
57
61
  def self.default_message = "provider server error"
58
62
  end
59
63
 
64
+ # An inbound body or protocol record crossed its configured byte boundary.
65
+ class ResponseTooLargeError < Error
66
+ attr_reader :kind, :limit
67
+
68
+ def initialize(kind:, limit:)
69
+ @kind = kind
70
+ @limit = limit
71
+ label = kind.to_s.tr("_", " ")
72
+ super("#{label} exceeded the byte limit (#{limit} bytes)")
73
+ end
74
+ end
75
+
76
+ # A JSON protocol record crossed a structural boundary before parsing.
77
+ class ResponseTooComplexError < Error
78
+ attr_reader :kind, :limit
79
+
80
+ def initialize(kind:, limit:)
81
+ @kind = kind
82
+ @limit = limit
83
+ label = kind.to_s.tr("_", " ")
84
+ super("#{label} exceeded the complexity limit (#{limit})")
85
+ end
86
+ end
87
+
88
+ # A non-replayable request has no confirmed response, so execution is unknown.
89
+ class AmbiguousDeliveryError < ProviderError
90
+ def self.default_message
91
+ "connection failed before the response was confirmed; the operation may have completed; " \
92
+ "do not retry automatically; verify external state first"
93
+ end
94
+ end
95
+
96
+ # The provider rejected the request itself: an invalid prompt, a bad
97
+ # image, a policy violation. Never retried; the same input cannot succeed.
98
+ class InvalidRequestError < ProviderError
99
+ def self.default_message = "provider rejected the request"
100
+ end
101
+
60
102
  # Tool arguments or structured output that violate their declared schema.
61
103
  class SchemaError < Error; end
62
104
 
63
105
  # A run cancelled by the host, raised only when the caller opts into raising.
64
106
  class AbortError < Error; end
65
107
 
66
- # A run stopped by its turn, token, cost, or wall-clock budget.
67
- class BudgetError < Error; end
108
+ # A cost-budgeted request returned without trustworthy accounting. Usage and
109
+ # provider_message preserve the uncertain attempt for host reconciliation.
110
+ class BudgetError < Error
111
+ attr_reader :usage, :provider_message
112
+
113
+ def initialize(message = nil, usage: nil, provider_message: nil)
114
+ @usage = usage
115
+ @provider_message = provider_message
116
+ super(message)
117
+ end
118
+ end
68
119
 
69
120
  # A text edit that did not match uniquely, or overlapped another edit.
70
121
  class EditError < Error; end
71
122
 
72
- # Compaction could not produce a usable summary.
73
- class CompactionError < Error; end
123
+ # A conditional document write lost to a different committed version. The
124
+ # revisions stay off the message so a stale full replacement cannot borrow
125
+ # a fresh token without reading the matching content.
126
+ class WorkspaceConflictError < Error
127
+ attr_reader :path, :expected_revision, :actual_revision
128
+
129
+ def initialize(path, expected_revision: nil, actual_revision: nil)
130
+ @path = path.to_s.dup.freeze
131
+ @expected_revision = expected_revision.nil? ? nil : expected_revision.to_s.dup.freeze
132
+ @actual_revision = actual_revision.nil? ? nil : actual_revision.to_s.dup.freeze
133
+ super("document #{@path.inspect} changed; read it again before retrying")
134
+ end
135
+ end
136
+
137
+ # Compaction could not produce a usable summary. Usage preserves any billed
138
+ # provider attempt even though no checkpoint was written.
139
+ class CompactionError < Error
140
+ attr_reader :usage
141
+
142
+ def initialize(message = nil, usage: nil)
143
+ @usage = usage
144
+ super(message)
145
+ end
146
+ end
74
147
 
75
148
  # The machine-readable shape of a stream failure, carried on errored
76
149
  # assistant messages so retry policies and hosts can classify without
@@ -84,6 +157,12 @@ module Mistri
84
157
  when RateLimitError
85
158
  { "type" => "RateLimitError", "status" => reason.status,
86
159
  "retry_after" => reason.retry_after }.compact
160
+ when ResponseTooLargeError
161
+ { "type" => "ResponseTooLargeError", "kind" => reason.kind.to_s,
162
+ "limit" => reason.limit }
163
+ when ResponseTooComplexError
164
+ { "type" => "ResponseTooComplexError", "kind" => reason.kind.to_s,
165
+ "limit" => reason.limit }
87
166
  when ProviderError
88
167
  { "type" => reason.class.name.split("::").last, "status" => reason.status }.compact
89
168
  when Exception then { "type" => reason.class.name }