mistri 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +480 -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 +11 -4
- 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 +190 -5
- 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/session.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Mistri
|
|
|
12
12
|
#
|
|
13
13
|
# A store implements two methods: append(id, entry_hash) and load(id) ->
|
|
14
14
|
# array of entry hashes in append order. Everything else lives here.
|
|
15
|
-
class Session
|
|
15
|
+
class Session # rubocop:disable Metrics/ClassLength -- append-only ordering is the class contract
|
|
16
16
|
attr_reader :id, :store
|
|
17
17
|
|
|
18
18
|
def initialize(store:, id: nil)
|
|
@@ -37,11 +37,30 @@ module Mistri
|
|
|
37
37
|
|
|
38
38
|
def entries = @store.load(@id)
|
|
39
39
|
|
|
40
|
+
# Every call ID is a session-wide correlation key. Read and validate the
|
|
41
|
+
# append-only history once, returning an owned set the loop can extend as
|
|
42
|
+
# it accepts later turns. Approval control entries are audited in the same
|
|
43
|
+
# ordered pass: they may only advance a prior assistant call.
|
|
44
|
+
def tool_control_state
|
|
45
|
+
audited = audit_history(entries)
|
|
46
|
+
approvals = audited.fetch(:approvals).map do |approval|
|
|
47
|
+
decision = approval[:decision] && own_json(approval[:decision])
|
|
48
|
+
{ call: rebuild_call(approval[:call]), decision: }
|
|
49
|
+
end
|
|
50
|
+
{ tool_call_ids: audited.fetch(:tool_call_ids), approvals: }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def tool_call_ids = tool_control_state.fetch(:tool_call_ids)
|
|
54
|
+
|
|
40
55
|
# The conversation as the model replays it.
|
|
41
56
|
def messages = replay.map(&:first)
|
|
42
57
|
|
|
43
58
|
# What a run killed mid-tool answers in place of the result it never got.
|
|
44
|
-
INTERRUPTED_RESULT = "[interrupted: the run stopped before this
|
|
59
|
+
INTERRUPTED_RESULT = "[interrupted: the run stopped before this result was persisted; " \
|
|
60
|
+
"the tool may have executed, so verify its effects before retrying]"
|
|
61
|
+
LEGACY_CALL_ID = /\Acall_[1-9]\d*\z/
|
|
62
|
+
LEGACY_CALL_PROVIDERS = %i[fake gemini].freeze
|
|
63
|
+
private_constant :LEGACY_CALL_ID, :LEGACY_CALL_PROVIDERS
|
|
45
64
|
|
|
46
65
|
# Replay messages paired with the entry index each came from, starting at
|
|
47
66
|
# the latest compaction boundary. The synthetic summary message carries a
|
|
@@ -51,15 +70,22 @@ module Mistri
|
|
|
51
70
|
# brick every later turn with a provider rejection, so unsettled calls
|
|
52
71
|
# get a synthesized interrupted result. Calls parked for human approval
|
|
53
72
|
# stay open; resume owns those.
|
|
54
|
-
def replay
|
|
73
|
+
def replay = replay_from(entries)
|
|
74
|
+
|
|
75
|
+
# Context accounting ignores provider usage reported before the latest
|
|
76
|
+
# compaction: those prompt counts describe the larger, pre-summary replay.
|
|
77
|
+
# Until a post-compaction assistant turn reports fresh usage, the compacted
|
|
78
|
+
# replay is estimated directly.
|
|
79
|
+
def context_tokens
|
|
55
80
|
log = entries
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
81
|
+
replay = replay_from(log)
|
|
82
|
+
compacted_at = log.rindex { |entry| entry["type"] == "compaction" }
|
|
83
|
+
usage_from = if compacted_at
|
|
84
|
+
replay.index { |(_, index)| index && index > compacted_at } || replay.length
|
|
85
|
+
else
|
|
86
|
+
0
|
|
87
|
+
end
|
|
88
|
+
Compaction.context_tokens(replay.map(&:first), usage_from:)
|
|
63
89
|
end
|
|
64
90
|
|
|
65
91
|
def last_compaction
|
|
@@ -83,11 +109,12 @@ module Mistri
|
|
|
83
109
|
# folds into the transcript at the next turn boundary as a labeled block
|
|
84
110
|
# the model can react to ("[Magpie finished] <report>"), while the typed
|
|
85
111
|
# entry keeps name, status, and the raw text for hosts to render as a
|
|
86
|
-
# report card rather than a fake user message.
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
112
|
+
# report card rather than a fake user message. Sequential redelivery of
|
|
113
|
+
# one child's report is dropped, and the return says which happened.
|
|
114
|
+
# Concurrent callers need their own serialization; with a lock adapter,
|
|
115
|
+
# the background runner and inactive cancellation use the child's lease
|
|
116
|
+
# for that. Reports normally arrive via SubAgent.run_dispatched; call this
|
|
117
|
+
# directly only from a custom dispatch path.
|
|
91
118
|
def deliver_report(name:, session_id:, status:, text: nil) # rubocop:disable Naming/PredicateMethod
|
|
92
119
|
already = entries.any? do |entry|
|
|
93
120
|
entry["type"] == "subagent_report" && entry["session_id"] == session_id
|
|
@@ -124,7 +151,8 @@ module Mistri
|
|
|
124
151
|
entries.filter_map do |entry|
|
|
125
152
|
next unless entry["type"] == "subagent"
|
|
126
153
|
|
|
127
|
-
Child.new(name: entry["name"], session_id: entry["session_id"], store: @store
|
|
154
|
+
Child.new(name: entry["name"], session_id: entry["session_id"], store: @store,
|
|
155
|
+
parent_session_id: @id)
|
|
128
156
|
end
|
|
129
157
|
end
|
|
130
158
|
|
|
@@ -149,45 +177,490 @@ module Mistri
|
|
|
149
177
|
# Parked tool calls not yet settled by a tool result, each with its
|
|
150
178
|
# decision when one has been recorded. Derived from the entry log alone,
|
|
151
179
|
# so it survives crashes and reads the same from every process.
|
|
152
|
-
def open_approvals
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
180
|
+
def open_approvals = tool_control_state.fetch(:approvals)
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
def tool_calls_from(entry)
|
|
185
|
+
Array(entry.dig("message", "content")).select do |block|
|
|
186
|
+
block.is_a?(Hash) && block["type"].to_s == "tool_call"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def record_assistant_calls(entry, message, index, audit)
|
|
191
|
+
calls = audit.fetch(:calls)
|
|
192
|
+
unresolved = audit.fetch(:unresolved)
|
|
193
|
+
reserved = audit.fetch(:reserved)
|
|
194
|
+
batch = { calls: [], provider_call_ids: {} }
|
|
195
|
+
parsed_calls = message.tool_calls
|
|
196
|
+
tool_calls_from(entry).each_with_index do |call, position|
|
|
197
|
+
id = validated_persisted_call(call)
|
|
198
|
+
source_call = parsed_calls.fetch(position)
|
|
199
|
+
reused = reused_legacy_call(id, source_call, message.provider, calls, reserved)
|
|
200
|
+
owned = id.dup.freeze
|
|
201
|
+
provider_id = source_call.provider_call_id
|
|
202
|
+
if provider_id && batch[:provider_call_ids].key?(provider_id)
|
|
203
|
+
raise ConfigurationError, "session contains a duplicate provider tool call ID"
|
|
163
204
|
end
|
|
205
|
+
|
|
206
|
+
batch[:provider_call_ids][provider_id] = true if provider_id
|
|
207
|
+
state = { source: call, source_call:,
|
|
208
|
+
source_provider: message.provider,
|
|
209
|
+
source_stop_reason: message.stop_reason,
|
|
210
|
+
source_index: index, source_position: position,
|
|
211
|
+
batch:, status: :pending, reused: !reused.nil? }
|
|
212
|
+
calls[owned] = state
|
|
213
|
+
batch[:calls] << state
|
|
214
|
+
audit.fetch(:states) << state
|
|
215
|
+
unresolved << owned
|
|
216
|
+
reserved << owned
|
|
164
217
|
end
|
|
165
|
-
requests.reject { |call| answered.include?(call["id"]) }
|
|
166
|
-
.map { |call| { call: rebuild_call(call), decision: decisions[call["id"]] } }
|
|
167
218
|
end
|
|
168
219
|
|
|
169
|
-
|
|
220
|
+
# Gemini and Fake once synthesized call_N from a per-turn counter. Only
|
|
221
|
+
# that known shape without provider correlation IDs may repeat; widening
|
|
222
|
+
# the exception would turn the session correlation key back into a guess.
|
|
223
|
+
def reused_legacy_call(id, source_call, provider, calls, reserved)
|
|
224
|
+
return nil unless reserved.include?(id)
|
|
225
|
+
|
|
226
|
+
prior = calls[id]
|
|
227
|
+
safe_status = prior && %i[answered interrupted].include?(prior[:status])
|
|
228
|
+
legacy_shape = LEGACY_CALL_ID.match?(id) && LEGACY_CALL_PROVIDERS.include?(provider) &&
|
|
229
|
+
LEGACY_CALL_PROVIDERS.include?(prior&.fetch(:source_provider, nil)) &&
|
|
230
|
+
source_call.provider_call_id.nil? &&
|
|
231
|
+
prior&.fetch(:source_call)&.provider_call_id.nil?
|
|
232
|
+
unless safe_status && legacy_shape
|
|
233
|
+
raise ConfigurationError, "session contains a duplicate tool call ID"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
prior
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def audit_history(log)
|
|
240
|
+
calls = {}
|
|
241
|
+
unresolved = Set.new
|
|
242
|
+
reserved = Set.new
|
|
243
|
+
states = []
|
|
244
|
+
approval_ids = Set.new
|
|
245
|
+
audit = { calls:, unresolved:, reserved:, states:, approval_ids: }
|
|
246
|
+
latest_compaction = nil
|
|
247
|
+
|
|
248
|
+
log.each_with_index do |entry, index|
|
|
249
|
+
unless entry.is_a?(Hash)
|
|
250
|
+
raise ConfigurationError, "session contains an entry that is not an object"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
message = validate_message_entry!(entry) if entry["type"] == "message"
|
|
254
|
+
|
|
255
|
+
if message&.tool?
|
|
256
|
+
record_tool_result(entry, index, calls, unresolved)
|
|
257
|
+
elsif message
|
|
258
|
+
close_unsettled_calls(calls, unresolved)
|
|
259
|
+
record_assistant_calls(entry, message, index, audit) if message.assistant?
|
|
260
|
+
elsif entry["type"] == "approval_request"
|
|
261
|
+
record_approval_call(entry, calls, audit)
|
|
262
|
+
elsif entry["type"] == "approval_decision"
|
|
263
|
+
record_approval_decision(entry, calls)
|
|
264
|
+
elsif entry["type"] == "compaction"
|
|
265
|
+
latest_compaction = entry
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
interrupt_ambiguous_reused_approvals!(states)
|
|
270
|
+
approvals = open_approval_states(states)
|
|
271
|
+
validate_compaction!(audit, approvals, latest_compaction)
|
|
272
|
+
{ tool_call_ids: reserved, approvals:, call_states: calls }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Control entries can authorize side effects, so every message in the
|
|
276
|
+
# same durable history must deserialize before any approval is exposed.
|
|
277
|
+
def validate_message_entry!(entry)
|
|
278
|
+
Message.from_h(entry["message"])
|
|
279
|
+
rescue StandardError => e
|
|
280
|
+
raise ConfigurationError,
|
|
281
|
+
"session contains an invalid persisted message (#{e.class.name})"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def record_tool_result(entry, index, calls, unresolved)
|
|
285
|
+
id = entry.dig("message", "tool_call_id")
|
|
286
|
+
problem = required_string_problem(id, "tool result call IDs")
|
|
287
|
+
raise ConfigurationError, "session contains #{problem}" if problem
|
|
288
|
+
|
|
289
|
+
state = calls[id]
|
|
290
|
+
unless state
|
|
291
|
+
raise ConfigurationError, "session contains a tool result without a prior assistant " \
|
|
292
|
+
"tool call"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
name = entry.dig("message", "tool_name")
|
|
296
|
+
problem = required_string_problem(name, "tool result names")
|
|
297
|
+
raise ConfigurationError, "session contains #{problem}" if problem
|
|
298
|
+
unless name == state[:source]["name"]
|
|
299
|
+
raise ConfigurationError, "session contains a tool result whose name does not match " \
|
|
300
|
+
"its assistant tool call"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
case state[:status]
|
|
304
|
+
when :pending
|
|
305
|
+
if state[:batch][:approval_phase_started]
|
|
306
|
+
raise ConfigurationError, "session contains a direct tool result after approval " \
|
|
307
|
+
"settlement began"
|
|
308
|
+
end
|
|
309
|
+
advance_call_order!(state, :direct_result_position, "tool results")
|
|
310
|
+
state[:status] = :answered
|
|
311
|
+
state[:result_index] = index
|
|
312
|
+
unresolved.delete(id)
|
|
313
|
+
when :decided
|
|
314
|
+
advance_call_order!(state, :approval_result_position, "approval tool results")
|
|
315
|
+
state[:status] = :answered
|
|
316
|
+
state[:result_index] = index
|
|
317
|
+
unresolved.delete(id)
|
|
318
|
+
when :approval_requested
|
|
319
|
+
raise ConfigurationError, "session contains a tool result for an approval without a " \
|
|
320
|
+
"prior decision"
|
|
321
|
+
when :answered
|
|
322
|
+
raise ConfigurationError, "session contains a duplicate tool result"
|
|
323
|
+
when :interrupted
|
|
324
|
+
raise ConfigurationError, "session contains a late tool result for a crash-healed call"
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def advance_call_order!(state, key, label)
|
|
329
|
+
previous = state[:batch][key]
|
|
330
|
+
if previous && state[:source_position] < previous
|
|
331
|
+
raise ConfigurationError, "session contains #{label} out of assistant call order"
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
state[:batch][key] = state[:source_position]
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def close_unsettled_calls(calls, unresolved)
|
|
338
|
+
unresolved.each do |id|
|
|
339
|
+
state = calls.fetch(id)
|
|
340
|
+
if state[:ambiguous_approval] &&
|
|
341
|
+
%i[approval_requested decided].include?(state[:status])
|
|
342
|
+
state[:status] = :interrupted
|
|
343
|
+
next
|
|
344
|
+
end
|
|
345
|
+
unless state[:status] == :pending
|
|
346
|
+
raise ConfigurationError, "session continues past an unsettled approval"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
state[:status] = :interrupted
|
|
350
|
+
end
|
|
351
|
+
unresolved.clear
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def record_approval_call(entry, calls, audit)
|
|
355
|
+
call = entry["call"]
|
|
356
|
+
unless call.is_a?(Hash)
|
|
357
|
+
raise ConfigurationError, "session contains an invalid approval request"
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
id = validated_persisted_call(call)
|
|
361
|
+
state = calls[id]
|
|
362
|
+
unless state
|
|
363
|
+
raise ConfigurationError, "session contains an approval request without a prior " \
|
|
364
|
+
"assistant tool call"
|
|
365
|
+
end
|
|
366
|
+
if state[:status] == :answered
|
|
367
|
+
raise ConfigurationError, "session contains an approval request for an already " \
|
|
368
|
+
"answered tool call"
|
|
369
|
+
end
|
|
370
|
+
if state[:status] == :interrupted
|
|
371
|
+
raise ConfigurationError, "session contains a stale approval request for a " \
|
|
372
|
+
"crash-healed tool call"
|
|
373
|
+
end
|
|
374
|
+
unless state[:status] == :pending
|
|
375
|
+
raise ConfigurationError, "session contains a duplicate approval request ID"
|
|
376
|
+
end
|
|
377
|
+
unless state[:source_stop_reason] == StopReason::TOOL_USE
|
|
378
|
+
raise ConfigurationError, "session contains an approval request for an assistant turn " \
|
|
379
|
+
"that did not stop for tool use"
|
|
380
|
+
end
|
|
381
|
+
validate_gemini_approval_order!(state)
|
|
382
|
+
validate_approval_provenance!(entry, call, state)
|
|
383
|
+
|
|
384
|
+
advance_call_order!(state, :approval_request_position, "approval requests")
|
|
385
|
+
state[:batch][:approval_phase_started] = true
|
|
386
|
+
state[:status] = :approval_requested
|
|
387
|
+
state[:approval] = { call:, decision: nil, source_index: state[:source_index] }
|
|
388
|
+
state[:ambiguous_approval] = state[:reused] && audit.fetch(:approval_ids).include?(id)
|
|
389
|
+
audit.fetch(:approval_ids) << id
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def validate_approval_provenance!(entry, call, state)
|
|
393
|
+
if entry["prepared_from"] == "assistant"
|
|
394
|
+
validate_normalizable_source!(state)
|
|
395
|
+
validate_prepared_call!(call, state[:source])
|
|
396
|
+
elsif entry.key?("prepared_from")
|
|
397
|
+
raise ConfigurationError, "session contains invalid approval provenance"
|
|
398
|
+
elsif entry.key?("source_call")
|
|
399
|
+
validate_approval_source!(entry["source_call"], call, state)
|
|
400
|
+
elsif !approval_mirrors?(state[:source], call)
|
|
401
|
+
raise ConfigurationError, "session contains an approval request that does not " \
|
|
402
|
+
"match its assistant tool call"
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Pre-ID Gemini calls pair same-name results by position. Once a later
|
|
407
|
+
# sibling has answered, resuming an earlier side effect would make replay
|
|
408
|
+
# ambiguous even when the durable history predates the live-run guard.
|
|
409
|
+
def validate_gemini_approval_order!(state)
|
|
410
|
+
source = state[:source_call]
|
|
411
|
+
return unless state[:source_provider] == :gemini && source.provider_call_id.nil?
|
|
412
|
+
|
|
413
|
+
unsafe = state[:batch][:calls].any? do |sibling|
|
|
414
|
+
sibling[:source_position] > state[:source_position] && sibling[:status] == :answered &&
|
|
415
|
+
sibling[:source_call].provider_call_id.nil? && sibling[:source_call].name == source.name
|
|
416
|
+
end
|
|
417
|
+
return unless unsafe
|
|
418
|
+
|
|
419
|
+
raise ConfigurationError, "session contains an ambiguous Gemini approval after a later " \
|
|
420
|
+
"same-name tool result"
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def record_approval_decision(entry, calls)
|
|
424
|
+
id = entry["call_id"]
|
|
425
|
+
problem = required_string_problem(id, "approval decision call IDs")
|
|
426
|
+
raise ConfigurationError, "session contains #{problem}" if problem
|
|
427
|
+
|
|
428
|
+
unless entry["approved"].equal?(true) || entry["approved"].equal?(false)
|
|
429
|
+
raise ConfigurationError, "session contains an approval decision whose approved " \
|
|
430
|
+
"value is not true or false"
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
state = calls[id]
|
|
434
|
+
unless state && state[:approval]
|
|
435
|
+
raise ConfigurationError, "session contains an approval decision without a prior " \
|
|
436
|
+
"matching approval request"
|
|
437
|
+
end
|
|
438
|
+
# Store order is the write-once register. A stale loser can append after
|
|
439
|
+
# execution, so it must not revoke the winner or corrupt the real result.
|
|
440
|
+
return if state[:approval][:decision]
|
|
441
|
+
|
|
442
|
+
state[:approval][:decision] = entry
|
|
443
|
+
state[:status] = :decided
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# An old decision names only call_N. Once that ID has already participated
|
|
447
|
+
# in approval, a later unsettled approval cannot prove which generation a
|
|
448
|
+
# delayed decision intended, so replay it as interrupted rather than risk
|
|
449
|
+
# executing a side effect under stale authorization.
|
|
450
|
+
def interrupt_ambiguous_reused_approvals!(states)
|
|
451
|
+
states.each do |state|
|
|
452
|
+
if state[:ambiguous_approval] && %i[approval_requested decided].include?(state[:status])
|
|
453
|
+
state[:status] = :interrupted
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def open_approval_states(states)
|
|
459
|
+
states.filter_map do |state|
|
|
460
|
+
state[:approval] if %i[approval_requested decided].include?(state[:status])
|
|
461
|
+
end
|
|
462
|
+
end
|
|
170
463
|
|
|
171
|
-
def
|
|
172
|
-
|
|
464
|
+
def validate_compaction!(audit, approvals, compaction)
|
|
465
|
+
return unless compaction
|
|
466
|
+
|
|
467
|
+
kept_from = compaction["kept_from"]
|
|
468
|
+
unless kept_from.is_a?(Integer) && kept_from >= 0
|
|
469
|
+
raise ConfigurationError, "session contains an invalid compaction boundary"
|
|
470
|
+
end
|
|
471
|
+
if approvals.any? { |approval| approval[:source_index] < kept_from }
|
|
472
|
+
raise ConfigurationError, "session contains an open approval whose assistant tool call " \
|
|
473
|
+
"was removed by compaction"
|
|
474
|
+
end
|
|
475
|
+
split = audit.fetch(:states).any? do |state|
|
|
476
|
+
state[:result_index] && state[:source_index] < kept_from &&
|
|
477
|
+
state[:result_index] >= kept_from
|
|
478
|
+
end
|
|
479
|
+
return unless split
|
|
480
|
+
|
|
481
|
+
raise ConfigurationError, "session contains a compaction boundary that splits a tool call " \
|
|
482
|
+
"from its result"
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def validate_approval_source!(source, prepared, state)
|
|
486
|
+
unless source.is_a?(Hash)
|
|
487
|
+
raise ConfigurationError, "session contains an invalid approval source call"
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
validated_persisted_call(source)
|
|
491
|
+
unless state[:source] && approval_mirrors?(state[:source], source)
|
|
492
|
+
raise ConfigurationError, "session contains an approval source that does not " \
|
|
493
|
+
"match its assistant tool call"
|
|
494
|
+
end
|
|
495
|
+
return if approval_mirrors?(prepared, source)
|
|
496
|
+
|
|
497
|
+
validate_normalizable_source!(state)
|
|
498
|
+
return if prepared_call_from_source?(prepared, source)
|
|
499
|
+
|
|
500
|
+
raise ConfigurationError, "session contains prepared approval metadata that does not " \
|
|
501
|
+
"match its source call"
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def validate_normalizable_source!(state)
|
|
505
|
+
source = state[:source_call]
|
|
506
|
+
return if source && !source.arguments_error? && source.arguments.is_a?(Hash)
|
|
507
|
+
|
|
508
|
+
raise ConfigurationError, "session contains an approval source that could not have " \
|
|
509
|
+
"been normalized"
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def validate_prepared_call!(prepared, assistant)
|
|
513
|
+
return if prepared_call_from_source?(prepared, assistant)
|
|
514
|
+
|
|
515
|
+
raise ConfigurationError, "session contains prepared approval metadata that does not " \
|
|
516
|
+
"match its assistant tool call"
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def validated_persisted_call(call)
|
|
520
|
+
unless call["type"].to_s == "tool_call"
|
|
521
|
+
raise ConfigurationError, "session contains an invalid tool call type"
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
problem = required_string_problem(call["id"], "tool call IDs")
|
|
525
|
+
raise ConfigurationError, "session contains #{problem}" if problem
|
|
526
|
+
|
|
527
|
+
problem = required_string_problem(call["name"], "tool call names")
|
|
528
|
+
raise ConfigurationError, "session contains #{problem}" if problem
|
|
529
|
+
|
|
530
|
+
validate_optional_string!(call["signature"], "tool call signatures")
|
|
531
|
+
validate_optional_string!(call["provider_call_id"], "provider tool call IDs")
|
|
532
|
+
call["id"]
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def validate_optional_string!(value, label)
|
|
536
|
+
return if value.nil?
|
|
537
|
+
|
|
538
|
+
problem = required_string_problem(value, label)
|
|
539
|
+
raise ConfigurationError, "session contains #{problem}" if problem
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def required_string_problem(value, label)
|
|
543
|
+
return "#{label} with no value" if value.nil?
|
|
544
|
+
return "#{label} that are not strings" unless value.is_a?(String)
|
|
545
|
+
unless value.encoding == Encoding::UTF_8 && value.valid_encoding?
|
|
546
|
+
return "#{label} that are not valid UTF-8"
|
|
547
|
+
end
|
|
548
|
+
return "#{label} that are blank" if value.match?(/\A[[:space:]]*\z/)
|
|
549
|
+
|
|
550
|
+
nil
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def approval_mirrors?(assistant, approval)
|
|
554
|
+
assistant == approval
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def prepared_call_from_source?(prepared, source)
|
|
558
|
+
%w[id name signature provider_call_id].all? do |field|
|
|
559
|
+
persisted_call_field(prepared, field) == persisted_call_field(source, field)
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def persisted_call_field(call, field)
|
|
564
|
+
call[field]
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def replay_from(log)
|
|
568
|
+
compaction = log.reverse_each.find { |entry| entry["type"] == "compaction" }
|
|
569
|
+
from = compaction ? compaction["kept_from"] : 0
|
|
570
|
+
pairs = log.each_with_index.filter_map do |entry, index|
|
|
571
|
+
next unless index >= from && entry["type"] == "message"
|
|
572
|
+
|
|
573
|
+
[Message.from_h(entry["message"]), index]
|
|
574
|
+
end
|
|
575
|
+
pairs = heal(pairs, replay_call_states(log, from:))
|
|
576
|
+
compaction ? [[summary_message(compaction["summary"]), nil], *pairs] : pairs
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# Replay only needs occurrence pairing, not the authorization audit. Keep
|
|
580
|
+
# it tolerant enough to render a rejected history while still ensuring a
|
|
581
|
+
# reused call_N cannot borrow an earlier result or approval.
|
|
582
|
+
def replay_call_states(log, from:)
|
|
583
|
+
replay = { active: {}, seen: Set.new, approval_ids: Set.new, states: [], from: }
|
|
584
|
+
log.each_with_index do |entry, index|
|
|
585
|
+
if entry["type"] == "message"
|
|
586
|
+
track_replay_message(entry, index, replay)
|
|
587
|
+
else
|
|
588
|
+
track_replay_control(entry, replay)
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
replay.fetch(:states).each do |state|
|
|
592
|
+
next unless state[:ambiguous_approval]
|
|
593
|
+
next unless %i[approval_requested decided].include?(state[:status])
|
|
594
|
+
|
|
595
|
+
state[:status] = :interrupted
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def track_replay_message(entry, index, replay)
|
|
600
|
+
role = entry.dig("message", "role").to_s
|
|
601
|
+
if role == "assistant"
|
|
602
|
+
tool_calls_from(entry).each_with_index do |call, position|
|
|
603
|
+
id = call["id"]
|
|
604
|
+
state = { source_index: index, source_position: position, status: :pending,
|
|
605
|
+
reused: replay.fetch(:seen).include?(id), approval: false }
|
|
606
|
+
replay.fetch(:states) << state if index >= replay.fetch(:from)
|
|
607
|
+
replay.fetch(:active)[id] = state
|
|
608
|
+
replay.fetch(:seen) << id
|
|
609
|
+
end
|
|
610
|
+
elsif role == "tool" &&
|
|
611
|
+
(state = replay.fetch(:active).delete(entry.dig("message", "tool_call_id")))
|
|
612
|
+
state[:status] = :answered
|
|
613
|
+
state[:result_index] = index
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def track_replay_control(entry, replay)
|
|
618
|
+
if entry["type"] == "approval_request"
|
|
619
|
+
id = entry.dig("call", "id")
|
|
620
|
+
return unless (state = replay.fetch(:active)[id])
|
|
621
|
+
|
|
622
|
+
state[:status] = :approval_requested
|
|
623
|
+
state[:approval] = true
|
|
624
|
+
state[:ambiguous_approval] = state[:reused] && replay.fetch(:approval_ids).include?(id)
|
|
625
|
+
replay.fetch(:approval_ids) << id
|
|
626
|
+
elsif entry["type"] == "approval_decision"
|
|
627
|
+
state = replay.fetch(:active)[entry["call_id"]]
|
|
628
|
+
state[:status] = :decided if state&.dig(:status) == :approval_requested
|
|
629
|
+
end
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def heal(pairs, states)
|
|
633
|
+
by_source = states.group_by { |state| state[:source_index] }
|
|
634
|
+
by_result = pairs.to_h { |message, index| [index, [message, index]] }
|
|
635
|
+
consumed = states.filter_map { |state| state[:result_index] }.to_set
|
|
173
636
|
pairs.flat_map do |message, index|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
637
|
+
next [] if consumed.include?(index)
|
|
638
|
+
|
|
639
|
+
batch = by_source[index]
|
|
640
|
+
next [[message, index]] unless message.assistant? && batch
|
|
641
|
+
|
|
642
|
+
[[message, index], *healed_results(message, batch, by_result)]
|
|
643
|
+
end
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
def healed_results(message, states, by_result)
|
|
647
|
+
calls = message.tool_calls
|
|
648
|
+
[false, true].flat_map do |approval_phase|
|
|
649
|
+
states.select { |state| state[:approval] == approval_phase }
|
|
650
|
+
.sort_by { |state| state[:source_position] }
|
|
651
|
+
.filter_map do |state|
|
|
652
|
+
if state[:status] == :answered
|
|
653
|
+
by_result.fetch(state[:result_index])
|
|
654
|
+
elsif !approval_phase || state[:status] == :interrupted
|
|
655
|
+
interrupted_pair(calls.fetch(state[:source_position]))
|
|
656
|
+
end
|
|
184
657
|
end
|
|
185
658
|
end
|
|
186
659
|
end
|
|
187
660
|
|
|
188
|
-
def
|
|
189
|
-
|
|
190
|
-
|
|
661
|
+
def interrupted_pair(call)
|
|
662
|
+
[Message.tool(content: INTERRUPTED_RESULT, tool_call_id: call.id,
|
|
663
|
+
tool_name: call.name, tool_error: true), nil]
|
|
191
664
|
end
|
|
192
665
|
|
|
193
666
|
def summary_message(summary)
|
|
@@ -228,18 +701,57 @@ module Mistri
|
|
|
228
701
|
end
|
|
229
702
|
|
|
230
703
|
def decide(call_id, approved:, note:)
|
|
231
|
-
|
|
704
|
+
open = open_approvals.find { |approval| approval[:call].id == call_id }
|
|
705
|
+
unless open
|
|
706
|
+
winner = recorded_approval_decision(call_id)
|
|
707
|
+
return nil if winner && winner["approved"] == approved
|
|
708
|
+
if winner
|
|
709
|
+
raise ConfigurationError, "approval for #{call_id.inspect} has already been decided"
|
|
710
|
+
end
|
|
711
|
+
|
|
232
712
|
raise ConfigurationError, "no open approval for #{call_id.inspect}"
|
|
233
713
|
end
|
|
234
714
|
|
|
715
|
+
if open[:decision]
|
|
716
|
+
return nil if open[:decision]["approved"] == approved
|
|
717
|
+
|
|
718
|
+
raise ConfigurationError, "approval for #{call_id.inspect} has already been decided"
|
|
719
|
+
end
|
|
720
|
+
|
|
235
721
|
entry = { "call_id" => call_id, "approved" => approved }
|
|
236
722
|
entry["note"] = note if note
|
|
237
723
|
append("approval_decision", entry)
|
|
724
|
+
|
|
725
|
+
# The two-method Store contract cannot combine the precheck and append.
|
|
726
|
+
# Re-read its total order so a racing conflicting caller learns it lost.
|
|
727
|
+
winner = recorded_approval_decision(call_id)
|
|
728
|
+
return nil if winner && winner["approved"] == approved
|
|
729
|
+
|
|
730
|
+
raise ConfigurationError, "approval for #{call_id.inspect} has already been decided"
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def recorded_approval_decision(call_id)
|
|
734
|
+
state = audit_history(entries).fetch(:call_states)[call_id]
|
|
735
|
+
return nil if state&.fetch(:ambiguous_approval, false)
|
|
736
|
+
|
|
737
|
+
state&.dig(:approval, :decision)
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def own_json(value)
|
|
741
|
+
case value
|
|
742
|
+
when Hash then value.to_h { |key, nested| [own_json(key), own_json(nested)] }
|
|
743
|
+
when Array then value.map { |nested| own_json(nested) }
|
|
744
|
+
when String then value.dup
|
|
745
|
+
else value
|
|
746
|
+
end
|
|
238
747
|
end
|
|
239
748
|
|
|
240
749
|
def rebuild_call(hash)
|
|
750
|
+
arguments = hash.key?("arguments") ? hash["arguments"] : {}
|
|
241
751
|
ToolCall.new(id: hash["id"], name: hash["name"],
|
|
242
|
-
arguments
|
|
752
|
+
arguments:, signature: hash["signature"],
|
|
753
|
+
arguments_error: hash["arguments_error"],
|
|
754
|
+
provider_call_id: hash["provider_call_id"])
|
|
243
755
|
end
|
|
244
|
-
end
|
|
756
|
+
end # rubocop:enable Metrics/ClassLength
|
|
245
757
|
end
|