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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +469 -4
- data/CONTRIBUTING.md +52 -0
- data/README.md +289 -385
- data/SECURITY.md +40 -0
- data/UPGRADING.md +640 -0
- data/assets/logo-animated.svg +30 -0
- data/assets/logo-dark.svg +14 -0
- data/assets/logo-light.svg +14 -0
- data/assets/logo.svg +14 -0
- data/assets/social-preview.png +0 -0
- data/docs/README.md +87 -0
- data/docs/context-and-workspaces.md +378 -0
- data/docs/mcp.md +366 -0
- data/docs/reliability.md +450 -0
- data/docs/sessions.md +295 -0
- data/docs/sub-agents.md +401 -0
- data/docs/tool-contracts.md +324 -0
- data/examples/approval.rb +36 -0
- data/examples/browser.rb +27 -0
- data/examples/page_editor.rb +31 -0
- data/examples/quickstart.rb +21 -0
- data/lib/generators/mistri/install/install_generator.rb +7 -3
- data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
- data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
- data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
- data/lib/mistri/agent.rb +575 -55
- data/lib/mistri/budget.rb +26 -1
- data/lib/mistri/child.rb +72 -16
- data/lib/mistri/compaction.rb +26 -10
- data/lib/mistri/compactor.rb +34 -11
- data/lib/mistri/console.rb +28 -7
- data/lib/mistri/content.rb +9 -3
- data/lib/mistri/dispatchers.rb +14 -12
- data/lib/mistri/errors.rb +83 -4
- data/lib/mistri/event.rb +24 -8
- data/lib/mistri/event_delivery.rb +60 -0
- data/lib/mistri/locks.rb +3 -3
- data/lib/mistri/mcp/client.rb +74 -19
- data/lib/mistri/mcp/egress.rb +216 -0
- data/lib/mistri/mcp/oauth.rb +476 -127
- data/lib/mistri/mcp/wires.rb +115 -23
- data/lib/mistri/mcp.rb +42 -8
- data/lib/mistri/message.rb +21 -11
- data/lib/mistri/models.rb +160 -22
- data/lib/mistri/providers/anthropic/assembler.rb +282 -44
- data/lib/mistri/providers/anthropic/serializer.rb +14 -9
- data/lib/mistri/providers/anthropic.rb +29 -6
- data/lib/mistri/providers/fake.rb +26 -10
- data/lib/mistri/providers/gemini/assembler.rb +148 -21
- data/lib/mistri/providers/gemini/serializer.rb +78 -9
- data/lib/mistri/providers/gemini.rb +31 -5
- data/lib/mistri/providers/openai/assembler.rb +337 -60
- data/lib/mistri/providers/openai/serializer.rb +13 -12
- data/lib/mistri/providers/openai.rb +29 -5
- data/lib/mistri/providers/schema_capabilities.rb +214 -0
- data/lib/mistri/result.rb +1 -1
- data/lib/mistri/schema.rb +893 -75
- data/lib/mistri/session.rb +560 -48
- data/lib/mistri/sinks/coalesced.rb +17 -10
- data/lib/mistri/spawner.rb +111 -61
- data/lib/mistri/sse.rb +57 -14
- data/lib/mistri/stores/active_record.rb +1 -1
- data/lib/mistri/stores/memory.rb +21 -2
- data/lib/mistri/sub_agent/execution.rb +81 -0
- data/lib/mistri/sub_agent/runtime.rb +297 -0
- data/lib/mistri/sub_agent.rb +124 -87
- data/lib/mistri/task_output.rb +24 -6
- data/lib/mistri/tool.rb +93 -13
- data/lib/mistri/tool_arguments.rb +377 -0
- data/lib/mistri/tool_call.rb +43 -9
- data/lib/mistri/tool_context.rb +4 -2
- data/lib/mistri/tool_executor.rb +117 -26
- data/lib/mistri/tool_result.rb +15 -10
- data/lib/mistri/tools/edit_file.rb +62 -8
- data/lib/mistri/tools.rb +41 -4
- data/lib/mistri/transport.rb +149 -44
- data/lib/mistri/usage.rb +65 -13
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri/workspace/active_record.rb +183 -3
- data/lib/mistri/workspace/directory.rb +28 -8
- data/lib/mistri/workspace/memory.rb +34 -9
- data/lib/mistri/workspace/single.rb +62 -5
- data/lib/mistri/workspace.rb +39 -0
- data/lib/mistri.rb +6 -1
- data/mistri.gemspec +34 -0
- 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
|
-
|
|
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 #
|
|
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
|
-
#
|
|
24
|
-
#
|
|
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
|
-
#
|
|
54
|
-
#
|
|
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
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
# needs a lock adapter; without one this
|
|
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
|
-
|
|
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
|
data/lib/mistri/compaction.rb
CHANGED
|
@@ -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).
|
|
25
|
-
#
|
|
26
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
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
|
data/lib/mistri/compactor.rb
CHANGED
|
@@ -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
|
|
13
|
-
#
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
data/lib/mistri/console.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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"]}(#{
|
|
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
|
|
data/lib/mistri/content.rb
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
93
|
-
|
|
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
|
data/lib/mistri/dispatchers.rb
CHANGED
|
@@ -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:
|
|
6
|
-
# parent_session_id, type, instructions, tool_names, model,
|
|
7
|
-
#
|
|
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
|
|
17
|
-
# children
|
|
18
|
-
# outlive the parent's turn (a broadcast lambda does,
|
|
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
|
-
|
|
38
|
-
#
|
|
39
|
-
#
|
|
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
|
-
"#{
|
|
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
|
|
67
|
-
|
|
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
|
-
#
|
|
73
|
-
|
|
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 }
|