mistri 0.4.1 → 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 +596 -3
- data/CONTRIBUTING.md +52 -0
- data/README.md +291 -306
- 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/abort_signal.rb +10 -0
- data/lib/mistri/agent.rb +635 -108
- data/lib/mistri/budget.rb +26 -1
- data/lib/mistri/child.rb +186 -0
- data/lib/mistri/compaction.rb +26 -10
- data/lib/mistri/compactor.rb +35 -12
- data/lib/mistri/console.rb +209 -0
- data/lib/mistri/content.rb +9 -3
- data/lib/mistri/dispatchers.rb +49 -0
- data/lib/mistri/errors.rb +83 -4
- data/lib/mistri/event.rb +30 -8
- data/lib/mistri/event_delivery.rb +60 -0
- data/lib/mistri/locks/rails_cache.rb +48 -0
- data/lib/mistri/locks.rb +141 -0
- 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 +43 -9
- 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 +36 -6
- 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 +8 -3
- data/lib/mistri/retry_policy.rb +2 -2
- data/lib/mistri/schema.rb +893 -75
- data/lib/mistri/session.rb +649 -47
- data/lib/mistri/sinks/coalesced.rb +17 -10
- data/lib/mistri/skill.rb +1 -1
- data/lib/mistri/skills.rb +1 -1
- data/lib/mistri/spawner.rb +316 -0
- data/lib/mistri/sse.rb +57 -14
- data/lib/mistri/stores/active_record.rb +22 -7
- data/lib/mistri/stores/jsonl.rb +3 -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 +238 -103
- data/lib/mistri/task_output.rb +58 -0
- data/lib/mistri/tool.rb +102 -13
- data/lib/mistri/tool_arguments.rb +377 -0
- data/lib/mistri/tool_call.rb +43 -9
- data/lib/mistri/tool_context.rb +7 -5
- 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 +17 -1
- data/mistri.gemspec +34 -0
- metadata +38 -3
data/lib/mistri/agent.rb
CHANGED
|
@@ -13,9 +13,20 @@ module Mistri
|
|
|
13
13
|
# marked needs_approval suspends the run instead of executing: the run
|
|
14
14
|
# returns at once (no thread ever waits on a human), the decision arrives
|
|
15
15
|
# later as a session entry from any process, and resume settles it and
|
|
16
|
-
# carries on.
|
|
17
|
-
#
|
|
18
|
-
|
|
16
|
+
# carries on. A tool marked ends_turn ends the run once it executes: the
|
|
17
|
+
# model does not get another word, and whatever comes next (a human's
|
|
18
|
+
# answer to ask_user, say) arrives as the next run's input. Session#steer
|
|
19
|
+
# queues a user message from any process while the loop runs; it folds
|
|
20
|
+
# into the transcript at the next turn boundary, and a background child's
|
|
21
|
+
# report arrives through the same inbox.
|
|
22
|
+
class Agent # rubocop:disable Metrics/ClassLength -- lifecycle order is the class contract
|
|
23
|
+
MAX_ARGUMENT_VIOLATIONS = 8
|
|
24
|
+
MAX_ARGUMENT_ERROR_BYTES = 2048
|
|
25
|
+
MAX_VIOLATION_SOURCE_BYTES = 4096
|
|
26
|
+
TOOL_ARGUMENT_VALIDATOR = Tool.instance_method(:validate_arguments)
|
|
27
|
+
private_constant :MAX_ARGUMENT_VIOLATIONS, :MAX_ARGUMENT_ERROR_BYTES,
|
|
28
|
+
:MAX_VIOLATION_SOURCE_BYTES, :TOOL_ARGUMENT_VALIDATOR
|
|
29
|
+
|
|
19
30
|
# compaction defaults on so long sessions survive their context window;
|
|
20
31
|
# pass false to disable, or a tuned Compaction. It only ever triggers
|
|
21
32
|
# when the model's window is known (catalog or Compaction#window).
|
|
@@ -40,7 +51,10 @@ module Mistri
|
|
|
40
51
|
@tools_by_name = @tools.to_h { |tool| [tool.name, tool] }
|
|
41
52
|
raise ConfigurationError, "duplicate tool names" if @tools_by_name.length != @tools.length
|
|
42
53
|
|
|
54
|
+
@tool_specs, @external_tool_validators = compile_tool_contracts
|
|
55
|
+
|
|
43
56
|
@budget = budget || Budget.new
|
|
57
|
+
@budget.validate_provider!(@provider)
|
|
44
58
|
@max_concurrency = max_concurrency
|
|
45
59
|
@transform_context = Array(transform_context)
|
|
46
60
|
@compaction = compaction || nil
|
|
@@ -59,7 +73,7 @@ module Mistri
|
|
|
59
73
|
# schema, natively where the provider supports it. task adds validation
|
|
60
74
|
# on top; run alone does not validate.
|
|
61
75
|
def run(input, images: [], signal: nil, output_schema: nil, &emit)
|
|
62
|
-
if
|
|
76
|
+
if refresh_tool_control.any?
|
|
63
77
|
raise ConfigurationError,
|
|
64
78
|
"session is awaiting approvals; call resume"
|
|
65
79
|
end
|
|
@@ -68,7 +82,7 @@ module Mistri
|
|
|
68
82
|
"run needs input text or images"
|
|
69
83
|
end
|
|
70
84
|
|
|
71
|
-
|
|
85
|
+
fold_inbox # anything queued while this session sat idle arrived first; keep that order
|
|
72
86
|
@session.append_message(Message.user_with_images(input, images))
|
|
73
87
|
loop_turns(signal, output_schema, &emit)
|
|
74
88
|
end
|
|
@@ -76,9 +90,10 @@ module Mistri
|
|
|
76
90
|
# Continue a suspended run. Undecided approvals return immediately, still
|
|
77
91
|
# suspended. Decided ones settle first: approved calls execute, denied
|
|
78
92
|
# calls answer in band so the model knows and can react. Then the loop
|
|
79
|
-
# carries on as if it never stopped
|
|
93
|
+
# carries on as if it never stopped, unless a settled call's tool ends
|
|
94
|
+
# the turn, in which case its execution was the run's last word.
|
|
80
95
|
def resume(signal: nil, &emit)
|
|
81
|
-
open =
|
|
96
|
+
open = refresh_tool_control
|
|
82
97
|
pending = open.select { |approval| approval[:decision].nil? }
|
|
83
98
|
if pending.any?
|
|
84
99
|
return Result.new(message: nil, status: :awaiting_approval,
|
|
@@ -86,7 +101,16 @@ module Mistri
|
|
|
86
101
|
usage: Usage.zero)
|
|
87
102
|
end
|
|
88
103
|
|
|
89
|
-
settle(open, signal, &emit)
|
|
104
|
+
executed = settle(open, signal, &emit)
|
|
105
|
+
if signal&.aborted?
|
|
106
|
+
last = @session.messages.reverse_each.find(&:assistant?)
|
|
107
|
+
return finished(last, Usage.zero, signal)
|
|
108
|
+
end
|
|
109
|
+
if executed.any? { |call| ends_turn?(call) }
|
|
110
|
+
last = @session.messages.reverse_each.find(&:assistant?)
|
|
111
|
+
return finished(last, Usage.zero, signal, handed_off: true)
|
|
112
|
+
end
|
|
113
|
+
|
|
90
114
|
loop_turns(signal, nil, &emit)
|
|
91
115
|
end
|
|
92
116
|
|
|
@@ -98,21 +122,26 @@ module Mistri
|
|
|
98
122
|
#
|
|
99
123
|
# A run that suspends for approval returns as-is: validation applies to
|
|
100
124
|
# completed runs only, so resume the session and re-ask if that happens
|
|
101
|
-
# mid-task.
|
|
125
|
+
# mid-task. A run an ends_turn tool ended returns as-is too: the floor
|
|
126
|
+
# belongs to whoever answers, and re-prompting for JSON would steal it
|
|
127
|
+
# back. Ask again once the answer arrives.
|
|
102
128
|
def task(input, schema:, images: [], signal: nil, fixes: 1, &emit)
|
|
103
|
-
|
|
104
|
-
|
|
129
|
+
plan = Schema.task_plan(schema)
|
|
130
|
+
result = run(
|
|
131
|
+
TaskOutput.prompt(input, plan), images:, signal:, output_schema: plan, &emit
|
|
132
|
+
)
|
|
105
133
|
spent = result.usage
|
|
106
134
|
fixes.downto(0) do |remaining|
|
|
107
135
|
result = result.with(usage: spent)
|
|
108
136
|
return result unless result.completed?
|
|
137
|
+
return result if result.handed_off?
|
|
109
138
|
|
|
110
|
-
value =
|
|
111
|
-
errors =
|
|
139
|
+
value = TaskOutput.parse(result.text)
|
|
140
|
+
errors = TaskOutput.errors(value, plan)
|
|
112
141
|
return result.with(output: value) if errors.empty?
|
|
113
142
|
raise SchemaError, "task output failed validation: #{errors.join("; ")}" if remaining.zero?
|
|
114
143
|
|
|
115
|
-
result = run(fix_prompt(errors), signal
|
|
144
|
+
result = run(TaskOutput.fix_prompt(errors), signal:, output_schema: plan, &emit)
|
|
116
145
|
spent += result.usage
|
|
117
146
|
end
|
|
118
147
|
end
|
|
@@ -121,7 +150,7 @@ module Mistri
|
|
|
121
150
|
# meters and near-limit warnings from this; window is nil for models the
|
|
122
151
|
# catalog does not know unless Compaction#window supplies one.
|
|
123
152
|
def context_usage
|
|
124
|
-
tokens =
|
|
153
|
+
tokens = @session.context_tokens
|
|
125
154
|
window = context_window
|
|
126
155
|
{ tokens: tokens, window: window,
|
|
127
156
|
fraction: window && (tokens.to_f / window).round(3) }
|
|
@@ -144,29 +173,36 @@ module Mistri
|
|
|
144
173
|
reason = @budget.exceeded(turns: turns, usage: usage, elapsed: monotonic_now - started)
|
|
145
174
|
return stop_for_budget(reason, usage, &emit) if reason
|
|
146
175
|
|
|
147
|
-
|
|
176
|
+
fold_inbox
|
|
148
177
|
compacted = auto_compact(&emit)
|
|
149
|
-
|
|
150
|
-
|
|
178
|
+
if compacted
|
|
179
|
+
compaction_usage = compacted[:usage] || Usage.new
|
|
180
|
+
validate_usage!(compaction_usage, kind: "compaction", &emit)
|
|
181
|
+
usage += compaction_usage
|
|
182
|
+
reason = @budget.exceeded(turns:, usage:, elapsed: monotonic_now - started)
|
|
183
|
+
return stop_for_budget(reason, usage, &emit) if reason
|
|
184
|
+
end
|
|
185
|
+
last, turn_usage = run_turn(signal, output_schema, &emit)
|
|
151
186
|
turns += 1
|
|
152
|
-
usage +=
|
|
187
|
+
usage += turn_usage
|
|
153
188
|
|
|
154
189
|
# Any tool call the turn made must be answered or parked, or the
|
|
155
190
|
# transcript is unpairable and replay fails.
|
|
156
|
-
parked = last.tool_calls? ? run_tools(last, signal, &emit) : []
|
|
191
|
+
parked, ended = last.tool_calls? ? run_tools(last, signal, &emit) : [[], false]
|
|
157
192
|
return suspended(last, parked, usage) if parked.any?
|
|
158
|
-
return finished(last, usage) if done?(last, signal)
|
|
193
|
+
return finished(last, usage, signal, handed_off: ended) if ended || done?(last, signal)
|
|
159
194
|
end
|
|
160
195
|
end
|
|
161
196
|
|
|
162
|
-
# A steer that lands while the model finishes
|
|
163
|
-
# more turn so it
|
|
164
|
-
#
|
|
197
|
+
# A steer or a child's report that lands while the model finishes
|
|
198
|
+
# cleanly extends the run one more turn, so it is answered rather than
|
|
199
|
+
# left dangling. Aborts, errors, and length stops always end the run;
|
|
200
|
+
# the inbox stays pending for the next one.
|
|
165
201
|
def done?(last, signal)
|
|
166
202
|
return false if last.stop_reason == StopReason::TOOL_USE && !signal&.aborted?
|
|
167
203
|
return true if signal&.aborted? || last.stop_reason != StopReason::STOP
|
|
168
204
|
|
|
169
|
-
@session.
|
|
205
|
+
@session.pending_inbox.empty?
|
|
170
206
|
end
|
|
171
207
|
|
|
172
208
|
# Compact when the context has grown into the reserve. A failed
|
|
@@ -175,25 +211,35 @@ module Mistri
|
|
|
175
211
|
def auto_compact(&)
|
|
176
212
|
return nil unless @compaction
|
|
177
213
|
|
|
178
|
-
tokens =
|
|
179
|
-
return nil unless @compaction.needed?(tokens, context_window
|
|
214
|
+
tokens = @session.context_tokens
|
|
215
|
+
return nil unless @compaction.needed?(tokens, context_window,
|
|
216
|
+
max_output: Models.shared_output(@provider.model))
|
|
180
217
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
218
|
+
compact_automatically(&)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def compact_automatically(&emit)
|
|
222
|
+
delivery = EventDelivery.wrap(emit)
|
|
223
|
+
Compactor.call(session: @session, provider: @provider, settings: @compaction, &delivery)
|
|
224
|
+
rescue EventDelivery::Failure => e
|
|
225
|
+
raise EventDelivery.unwrap(e, delivery)
|
|
226
|
+
rescue CompactionError => e
|
|
227
|
+
{ usage: e.usage || Usage.new }
|
|
184
228
|
end
|
|
185
229
|
|
|
186
230
|
def context_window
|
|
187
231
|
@compaction&.window || Models.find(@provider.model)&.context_window
|
|
188
232
|
end
|
|
189
233
|
|
|
190
|
-
# Materialize queued steers
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
#
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
234
|
+
# Materialize the inbox, queued steers and sub-agent reports alike, into
|
|
235
|
+
# the transcript in arrival order. The folded message entry carries the
|
|
236
|
+
# source entry's id under its marker key, which is what marks the entry
|
|
237
|
+
# consumed: one append is both the fold and the marker, so a crash
|
|
238
|
+
# between folds never double-delivers.
|
|
239
|
+
def fold_inbox
|
|
240
|
+
@session.pending_inbox.each do |entry|
|
|
241
|
+
marker = Session::INBOX.fetch(entry["type"])
|
|
242
|
+
@session.append("message", "message" => entry["message"], marker => entry["id"])
|
|
197
243
|
end
|
|
198
244
|
end
|
|
199
245
|
|
|
@@ -213,12 +259,18 @@ module Mistri
|
|
|
213
259
|
transform.call(messages)
|
|
214
260
|
end
|
|
215
261
|
attempt = 0
|
|
262
|
+
usage = Usage.zero
|
|
263
|
+
schema = provider_output_schema(output_schema)
|
|
216
264
|
loop do
|
|
217
265
|
held = nil
|
|
218
266
|
gate = emit && ->(event) { event.terminal? ? held = event : emit.call(event) }
|
|
219
267
|
message = @provider.stream(messages: history, system: @system,
|
|
220
|
-
tools: @
|
|
221
|
-
output_schema:
|
|
268
|
+
tools: @tool_specs, signal: signal,
|
|
269
|
+
output_schema: schema, &gate)
|
|
270
|
+
message, held = enforce_tool_call_envelope(message, held)
|
|
271
|
+
attempt_usage = message.usage || Usage.new
|
|
272
|
+
validate_usage!(attempt_usage, message:, kind: "turn", &emit)
|
|
273
|
+
usage += attempt_usage
|
|
222
274
|
attempt += 1
|
|
223
275
|
error = @retries&.error_for(message)
|
|
224
276
|
if retry_turn?(error, attempt, signal)
|
|
@@ -229,10 +281,18 @@ module Mistri
|
|
|
229
281
|
end
|
|
230
282
|
emit&.call(held) if held
|
|
231
283
|
@session.append_message(message)
|
|
232
|
-
|
|
284
|
+
reserve_tool_call_ids(message.tool_calls)
|
|
285
|
+
return [message, usage]
|
|
233
286
|
end
|
|
234
287
|
end
|
|
235
288
|
|
|
289
|
+
def provider_output_schema(output_schema)
|
|
290
|
+
return output_schema unless output_schema.respond_to?(:native_fallback?)
|
|
291
|
+
return output_schema.schema unless @provider.respond_to?(:native_output_schema)
|
|
292
|
+
|
|
293
|
+
@provider.native_output_schema(output_schema.schema)
|
|
294
|
+
end
|
|
295
|
+
|
|
236
296
|
def retry_turn?(error, attempt, signal)
|
|
237
297
|
return false unless @retries && error
|
|
238
298
|
return false if signal&.aborted?
|
|
@@ -241,8 +301,9 @@ module Mistri
|
|
|
241
301
|
end
|
|
242
302
|
|
|
243
303
|
def record_retry(message, error, attempt, pause, &emit)
|
|
244
|
-
|
|
245
|
-
|
|
304
|
+
entry = { "attempt" => attempt, "error" => error, "delay" => pause.round(2) }
|
|
305
|
+
entry["usage"] = (message.usage || Usage.new).to_h
|
|
306
|
+
@session.append("retry", entry)
|
|
246
307
|
note = format("attempt %<attempt>d failed; retrying in %<pause>.1fs",
|
|
247
308
|
attempt: attempt, pause: pause)
|
|
248
309
|
emit&.call(Event.new(type: :retry, content: note, attempt: attempt,
|
|
@@ -250,6 +311,22 @@ module Mistri
|
|
|
250
311
|
message: message))
|
|
251
312
|
end
|
|
252
313
|
|
|
314
|
+
def validate_usage!(usage, kind:, message: nil, &emit)
|
|
315
|
+
@budget.validate_usage!(usage)
|
|
316
|
+
rescue BudgetError => e
|
|
317
|
+
entry = { "kind" => kind, "usage" => usage.to_h }
|
|
318
|
+
entry["message"] = message.to_h if message
|
|
319
|
+
@session.append("unpriced_attempt", entry)
|
|
320
|
+
error = BudgetError.new(e.message, usage: e.usage || usage, provider_message: message)
|
|
321
|
+
stopped = Message.assistant(content: "Run stopped: cost could not be determined.",
|
|
322
|
+
stop_reason: StopReason::BUDGET,
|
|
323
|
+
error_message: "budget_cost_unknown")
|
|
324
|
+
@session.append_message(stopped)
|
|
325
|
+
emit&.call(Event.new(type: :error, reason: StopReason::BUDGET, message: stopped,
|
|
326
|
+
error_message: error.message))
|
|
327
|
+
raise error
|
|
328
|
+
end
|
|
329
|
+
|
|
253
330
|
# Backoff that an abort can cut short.
|
|
254
331
|
def wait(seconds, signal)
|
|
255
332
|
deadline = monotonic_now + seconds
|
|
@@ -258,46 +335,451 @@ module Mistri
|
|
|
258
335
|
|
|
259
336
|
# Answer or park the assistant's tool calls. Ungated calls execute (only
|
|
260
337
|
# on a genuine tool_use turn with no abort; otherwise they pair with
|
|
261
|
-
# interrupted results). Gated calls park as approval requests
|
|
262
|
-
#
|
|
338
|
+
# interrupted results). Gated calls park as approval requests. Nothing
|
|
339
|
+
# is left dangling either way. Returns the parked calls and whether an
|
|
340
|
+
# executed tool ends the turn; a parked call outranks an executed
|
|
341
|
+
# ends_turn, because a suspension is the stronger stop and the model
|
|
342
|
+
# regains the floor when the run resumes.
|
|
263
343
|
def run_tools(assistant, signal, &emit)
|
|
264
344
|
calls = assistant.tool_calls
|
|
265
345
|
unless assistant.stop_reason == StopReason::TOOL_USE && !signal&.aborted?
|
|
266
|
-
calls.each
|
|
267
|
-
|
|
346
|
+
calls.each do |call|
|
|
347
|
+
answer(call, ToolResult.new(content: ToolExecutor::INTERRUPTED, error: true), &emit)
|
|
348
|
+
end
|
|
349
|
+
return [[], false]
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
prepared, batch = prepare_tool_batch(calls, signal, &emit)
|
|
353
|
+
return interrupt_tool_turn(prepared, signal, &emit) unless batch
|
|
354
|
+
|
|
355
|
+
complete_tool_batch(calls, prepared, batch, signal, provider: assistant.provider, &emit)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def complete_tool_batch(source_calls, prepared, batch, signal, provider:, &emit)
|
|
359
|
+
batch = protect_gemini_result_order(prepared, batch) if provider == :gemini
|
|
360
|
+
invalid, blocked, parked, free, rejected = batch
|
|
361
|
+
executed = execute(free, signal, &emit)
|
|
362
|
+
if signal&.aborted?
|
|
363
|
+
uncommitted = [*invalid, *blocked, *rejected].map(&:first) + parked
|
|
364
|
+
results = [*executed, *interrupted_results(uncommitted)]
|
|
365
|
+
answer_results(prepared, results, executed:, signal:, &emit)
|
|
366
|
+
return [[], executed_ends_turn?(executed)]
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
answer_results(prepared, [*invalid, *blocked, *rejected, *executed],
|
|
370
|
+
executed:, signal:, &emit)
|
|
371
|
+
if signal&.aborted?
|
|
372
|
+
answer_results(prepared, interrupted_results(parked),
|
|
373
|
+
executed: [], signal:, &emit)
|
|
374
|
+
return [[], executed_ends_turn?(executed)]
|
|
268
375
|
end
|
|
269
376
|
|
|
270
|
-
|
|
271
|
-
execute(free, signal, &emit)
|
|
377
|
+
sources = source_calls.to_h { |call| [call.id, call] }
|
|
272
378
|
parked.each do |call|
|
|
273
|
-
|
|
379
|
+
source = sources.fetch(call.id)
|
|
380
|
+
data = { "call" => call.to_h }
|
|
381
|
+
data["prepared_from"] = "assistant" unless call == source
|
|
382
|
+
@session.append("approval_request", data)
|
|
274
383
|
emit&.call(Event.new(type: :approval_needed, tool_call: call))
|
|
275
384
|
end
|
|
276
|
-
parked
|
|
385
|
+
[parked, executed_ends_turn?(executed)]
|
|
277
386
|
end
|
|
278
387
|
|
|
279
|
-
def
|
|
388
|
+
def executed_ends_turn?(executed)
|
|
389
|
+
executed.any? { |call, _result, _seconds| ends_turn?(call) }
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Before Gemini 3, same-name parallel calls may have no wire IDs and only
|
|
393
|
+
# order pairs their responses. A later immediate result cannot pass an
|
|
394
|
+
# earlier parked result without becoming ambiguous.
|
|
395
|
+
def protect_gemini_result_order(prepared, batch)
|
|
396
|
+
invalid, blocked, parked, free, rejected = batch
|
|
397
|
+
positions = prepared.each_with_index.to_h { |call, index| [call.id, index] }
|
|
398
|
+
immediate = [*invalid, *blocked, *rejected].map(&:first) + free
|
|
399
|
+
unsafe, safe = parked.partition do |parked_call|
|
|
400
|
+
parked_call.provider_call_id.nil? && immediate.any? do |call|
|
|
401
|
+
call.provider_call_id.nil? && call.name == parked_call.name &&
|
|
402
|
+
positions.fetch(call.id) > positions.fetch(parked_call.id)
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
failures = unsafe.map do |call|
|
|
406
|
+
content = "Gemini returned parallel same-name calls without IDs across an approval " \
|
|
407
|
+
"boundary. This tool did not run; retry those calls separately."
|
|
408
|
+
[call, ToolResult.new(content:, error: true), nil]
|
|
409
|
+
end
|
|
410
|
+
[invalid, blocked, safe, free, [*rejected, *failures]]
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Returns the calls that actually executed (denied ones only answer).
|
|
414
|
+
def settle(open, signal, &)
|
|
280
415
|
approved, denied = open.partition { |approval| approval[:decision]["approved"] }
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
416
|
+
approved_calls = approved.map { |approval| approval[:call] }
|
|
417
|
+
_prepared, valid, invalid = prepare_calls(
|
|
418
|
+
approved_calls, normalize: false, signal:
|
|
419
|
+
)
|
|
420
|
+
return interrupt_settlement(open, approved_calls, denied, signal, &) if signal&.aborted?
|
|
421
|
+
|
|
422
|
+
cleared, blocked = screen(valid, signal, &)
|
|
423
|
+
return interrupt_settlement(open, approved_calls, denied, signal, &) if signal&.aborted?
|
|
424
|
+
|
|
425
|
+
executed = execute(cleared, signal, &)
|
|
426
|
+
denials = denial_results(denied)
|
|
427
|
+
results = if signal&.aborted?
|
|
428
|
+
uncommitted = [*invalid, *blocked].map(&:first)
|
|
429
|
+
[*executed, *interrupted_results(uncommitted), *denials]
|
|
430
|
+
else
|
|
431
|
+
[*invalid, *blocked, *executed, *denials]
|
|
432
|
+
end
|
|
433
|
+
answer_results(open.map { |approval| approval[:call] },
|
|
434
|
+
results, executed:, signal:, &)
|
|
435
|
+
executed.map(&:first)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def interrupt_settlement(open, approved, denied, signal, &)
|
|
439
|
+
results = [*interrupted_results(approved), *denial_results(denied)]
|
|
440
|
+
answer_results(open.map { |approval| approval[:call] }, results,
|
|
441
|
+
executed: [], signal:, &)
|
|
442
|
+
[]
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def denial_results(denied)
|
|
446
|
+
denied.map do |approval|
|
|
284
447
|
note = approval[:decision]["note"]
|
|
285
448
|
text = "The user denied this tool call#{note ? ": #{note}" : "."}"
|
|
286
|
-
|
|
449
|
+
[approval[:call], text, nil]
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Provider output is untrusted until the call has resolved to a current
|
|
454
|
+
# tool, passed its one allowed normalization, and satisfied both core and
|
|
455
|
+
# host schema checks. Rejections stay paired in band, while valid siblings
|
|
456
|
+
# continue through policy and execution.
|
|
457
|
+
def prepare_calls(calls, normalize:, signal: nil)
|
|
458
|
+
calls.each_with_object([[], [], []]) do |call, (ordered, valid, rejected)|
|
|
459
|
+
if signal&.aborted?
|
|
460
|
+
ordered << call
|
|
461
|
+
next
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
prepared, result = prepare_call(call, normalize:, signal:)
|
|
465
|
+
ordered << prepared
|
|
466
|
+
if result
|
|
467
|
+
rejected << [prepared, result, nil]
|
|
468
|
+
else
|
|
469
|
+
valid << prepared
|
|
470
|
+
end
|
|
287
471
|
end
|
|
288
472
|
end
|
|
289
473
|
|
|
474
|
+
def prepare_tool_batch(calls, signal, &)
|
|
475
|
+
prepared, valid, invalid = prepare_calls(calls, normalize: true, signal:)
|
|
476
|
+
return [prepared, nil] if signal&.aborted?
|
|
477
|
+
|
|
478
|
+
screened, blocked = screen(valid, signal, &)
|
|
479
|
+
return [prepared, nil] if signal&.aborted?
|
|
480
|
+
|
|
481
|
+
parked, free, rejected = partition_approval(screened, signal)
|
|
482
|
+
return [prepared, nil] if signal&.aborted?
|
|
483
|
+
|
|
484
|
+
[prepared, [invalid, blocked, parked, free, rejected]]
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def prepare_call(call, normalize:, signal: nil)
|
|
488
|
+
call = call.with unless call.arguments_owned?
|
|
489
|
+
tool = @tools_by_name[call.name]
|
|
490
|
+
return [call, unavailable_tool(call.name)] unless tool
|
|
491
|
+
|
|
492
|
+
if call.arguments_error
|
|
493
|
+
violation = if call.arguments_error == "invalid_json"
|
|
494
|
+
"arguments were not valid JSON"
|
|
495
|
+
else
|
|
496
|
+
"arguments were not valid bounded JSON"
|
|
497
|
+
end
|
|
498
|
+
return [call, argument_failure(call.name, violation)]
|
|
499
|
+
end
|
|
500
|
+
unless call.arguments.is_a?(Hash)
|
|
501
|
+
return [call, argument_failure(call.name, "arguments must be a JSON object")]
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
prepared, failure = normalize ? normalize_call(tool, call) : [call, nil]
|
|
505
|
+
return [prepared, nil] if signal&.aborted?
|
|
506
|
+
return [prepared, failure] if failure
|
|
507
|
+
|
|
508
|
+
[prepared, validate_call(tool, prepared)]
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Tool-result correlation is provider-owned opaque state. If any ID is
|
|
512
|
+
# unusable, the whole assistant attempt is unpairable: discard it before
|
|
513
|
+
# persistence and let the ordinary provider retry contract request a
|
|
514
|
+
# clean turn. Inventing an ID would corrupt Anthropic tool_use_id and
|
|
515
|
+
# OpenAI Responses call_id replay.
|
|
516
|
+
def enforce_tool_call_envelope(message, held)
|
|
517
|
+
unless message.is_a?(Message) && message.assistant?
|
|
518
|
+
return reject_provider_message(message, "provider turns must be assistant messages")
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
violation = tool_call_envelope_violation(message.tool_calls) ||
|
|
522
|
+
provider_tool_call_violation(message)
|
|
523
|
+
incomplete = message.tool_calls.any? { |call| call.arguments_error == "incomplete" }
|
|
524
|
+
return [message, held] unless violation || incomplete
|
|
525
|
+
|
|
526
|
+
if [StopReason::ERROR, StopReason::ABORTED, StopReason::LENGTH,
|
|
527
|
+
StopReason::BUDGET].include?(message.stop_reason)
|
|
528
|
+
sanitized = Message.assistant(content: message.content.grep_v(ToolCall),
|
|
529
|
+
model: message.model, provider: message.provider,
|
|
530
|
+
usage: message.usage, stop_reason: message.stop_reason,
|
|
531
|
+
error_message: message.error_message, error: message.error)
|
|
532
|
+
terminal = Event.new(type: held&.type || :error, reason: sanitized.stop_reason,
|
|
533
|
+
message: sanitized,
|
|
534
|
+
error_message: held&.error_message || sanitized.error_message)
|
|
535
|
+
return [sanitized, terminal]
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
text = "The provider returned malformed tool-call metadata. No tools ran."
|
|
539
|
+
detail = violation || "tool-call arguments were incomplete"
|
|
540
|
+
failure = ProviderError.new("#{text} #{detail}.")
|
|
541
|
+
rejected = Message.assistant(content: text, model: message.model,
|
|
542
|
+
provider: message.provider, usage: message.usage,
|
|
543
|
+
stop_reason: StopReason::ERROR,
|
|
544
|
+
error_message: failure.message,
|
|
545
|
+
error: ErrorData.for(failure))
|
|
546
|
+
terminal = Event.new(type: :error, reason: StopReason::ERROR, message: rejected,
|
|
547
|
+
error_message: failure.message)
|
|
548
|
+
[rejected, terminal]
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def reject_provider_message(message, reason)
|
|
552
|
+
text = "The provider returned an invalid message. No tools ran."
|
|
553
|
+
failure = ProviderError.new("#{text} #{reason}.")
|
|
554
|
+
usage = message.usage if message.is_a?(Message)
|
|
555
|
+
rejected = Message.assistant(content: text, usage:, stop_reason: StopReason::ERROR,
|
|
556
|
+
error_message: failure.message,
|
|
557
|
+
error: ErrorData.for(failure))
|
|
558
|
+
terminal = Event.new(type: :error, reason: StopReason::ERROR, message: rejected,
|
|
559
|
+
error_message: failure.message)
|
|
560
|
+
[rejected, terminal]
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Gemini cryptographically binds a function call to its continuation.
|
|
564
|
+
# Replacing malformed signed arguments with {} makes that continuation
|
|
565
|
+
# fail at the provider, so the only replay-safe boundary is the complete
|
|
566
|
+
# attempt: retry it without persisting any part of the bad turn.
|
|
567
|
+
def provider_tool_call_violation(message)
|
|
568
|
+
return unless message.provider == :gemini
|
|
569
|
+
return unless message.tool_calls.any? do |call|
|
|
570
|
+
call.arguments_error || !call.arguments.is_a?(Hash)
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
"Gemini function-call arguments must be a valid JSON object"
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def tool_call_envelope_violation(calls)
|
|
577
|
+
seen = {}
|
|
578
|
+
seen_provider_ids = {}
|
|
579
|
+
calls.each do |call|
|
|
580
|
+
problem = call_id_problem(call.id)
|
|
581
|
+
return problem if problem
|
|
582
|
+
|
|
583
|
+
return "tool call IDs must be unique within a session" if @tool_call_ids.include?(call.id)
|
|
584
|
+
|
|
585
|
+
problem = call_name_problem(call.name)
|
|
586
|
+
return problem if problem
|
|
587
|
+
|
|
588
|
+
problem = call_signature_problem(call.signature)
|
|
589
|
+
return problem if problem
|
|
590
|
+
|
|
591
|
+
if call.respond_to?(:provider_call_id)
|
|
592
|
+
problem = provider_call_id_problem(call.provider_call_id)
|
|
593
|
+
return problem if problem
|
|
594
|
+
if call.provider_call_id && seen_provider_ids.key?(call.provider_call_id)
|
|
595
|
+
return "provider tool call IDs must be unique within an assistant turn"
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
return "tool call IDs must be unique within an assistant turn" if seen.key?(call.id)
|
|
600
|
+
|
|
601
|
+
seen[call.id] = true
|
|
602
|
+
seen_provider_ids[call.provider_call_id] = true if call.provider_call_id
|
|
603
|
+
end
|
|
604
|
+
nil
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def refresh_tool_control
|
|
608
|
+
state = @session.tool_control_state
|
|
609
|
+
@tool_call_ids = state.fetch(:tool_call_ids)
|
|
610
|
+
state.fetch(:approvals)
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def reserve_tool_call_ids(calls)
|
|
614
|
+
calls.each { |call| @tool_call_ids << call.id }
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def call_name_problem(name)
|
|
618
|
+
return "a tool name was missing" if name.nil?
|
|
619
|
+
return "tool names must be strings" unless name.is_a?(String)
|
|
620
|
+
unless name.encoding == Encoding::UTF_8 && name.valid_encoding?
|
|
621
|
+
return "tool names must be valid UTF-8"
|
|
622
|
+
end
|
|
623
|
+
return "tool names must not be blank" if name.match?(/\A[[:space:]]*\z/)
|
|
624
|
+
|
|
625
|
+
nil
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def call_id_problem(id)
|
|
629
|
+
return "a tool call ID was missing" if id.nil?
|
|
630
|
+
return "tool call IDs must be strings" unless id.is_a?(String)
|
|
631
|
+
unless id.encoding == Encoding::UTF_8 && id.valid_encoding?
|
|
632
|
+
return "tool call IDs must be valid UTF-8"
|
|
633
|
+
end
|
|
634
|
+
return "tool call IDs must not be blank" if id.match?(/\A[[:space:]]*\z/)
|
|
635
|
+
|
|
636
|
+
nil
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def provider_call_id_problem(id)
|
|
640
|
+
return nil if id.nil?
|
|
641
|
+
return "provider tool call IDs must be strings" unless id.is_a?(String)
|
|
642
|
+
unless id.encoding == Encoding::UTF_8 && id.valid_encoding?
|
|
643
|
+
return "provider tool call IDs must be valid UTF-8"
|
|
644
|
+
end
|
|
645
|
+
return "provider tool call IDs must not be blank" if id.match?(/\A[[:space:]]*\z/)
|
|
646
|
+
|
|
647
|
+
nil
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def call_signature_problem(signature)
|
|
651
|
+
return nil if signature.nil?
|
|
652
|
+
return "tool call signatures must be strings" unless signature.is_a?(String)
|
|
653
|
+
unless signature.encoding == Encoding::UTF_8 && signature.valid_encoding?
|
|
654
|
+
return "tool call signatures must be valid UTF-8"
|
|
655
|
+
end
|
|
656
|
+
return "tool call signatures must not be blank" if signature.match?(/\A[[:space:]]*\z/)
|
|
657
|
+
|
|
658
|
+
nil
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
def normalize_call(tool, call)
|
|
662
|
+
return [call, nil] unless tool.respond_to?(:normalize_arguments)
|
|
663
|
+
|
|
664
|
+
normalized = tool.normalize_arguments(call.arguments)
|
|
665
|
+
prepared = normalized.equal?(call.arguments) ? call : call.with(arguments: normalized)
|
|
666
|
+
if prepared.arguments_error || !prepared.arguments.is_a?(Hash)
|
|
667
|
+
return [prepared, preparation_failure(call.name, "normalizer", TypeError)]
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
[prepared, nil]
|
|
671
|
+
rescue StandardError => e
|
|
672
|
+
[call, preparation_failure(call.name, "normalizer", e.class)]
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
def validate_call(tool, call)
|
|
676
|
+
validator = @external_tool_validators[call.name]
|
|
677
|
+
errors = if tool.is_a?(Tool)
|
|
678
|
+
TOOL_ARGUMENT_VALIDATOR.bind_call(tool, call.arguments, owned: true)
|
|
679
|
+
else
|
|
680
|
+
validator&.violations(call.arguments, owned: true) || []
|
|
681
|
+
end
|
|
682
|
+
if errors.empty? && !tool.is_a?(Tool) && tool.respond_to?(:prepared_argument_violations)
|
|
683
|
+
errors = tool.prepared_argument_violations(call.arguments)
|
|
684
|
+
end
|
|
685
|
+
errors.empty? ? nil : argument_failure(call.name, *errors)
|
|
686
|
+
rescue StandardError => e
|
|
687
|
+
preparation_failure(call.name, "validator", e.class)
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
def argument_failure(name, *violations)
|
|
691
|
+
header = "Invalid arguments for tool #{tool_label(name)}:\n"
|
|
692
|
+
footer = "\nThe tool did not run. Correct the arguments and try again."
|
|
693
|
+
available = MAX_ARGUMENT_ERROR_BYTES - header.bytesize - footer.bytesize
|
|
694
|
+
shown = violations.first(MAX_ARGUMENT_VIOLATIONS)
|
|
695
|
+
shown[-1] = "additional violations were omitted" if violations.length > shown.length
|
|
696
|
+
lines = shown.map do |violation|
|
|
697
|
+
"- #{safe_violation(violation)}"
|
|
698
|
+
end
|
|
699
|
+
body = utf8_prefix(lines.join("\n"), available)
|
|
700
|
+
ToolResult.new(content: "#{header}#{body}#{footer}", error: true)
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
def unavailable_tool(name)
|
|
704
|
+
content = "Unknown tool #{tool_label(name)}. The tool did not run; choose an available tool."
|
|
705
|
+
ToolResult.new(content: utf8_prefix(content, MAX_ARGUMENT_ERROR_BYTES), error: true)
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def preparation_failure(name, phase, error_class)
|
|
709
|
+
klass = safe_violation(error_class.name || "Exception")
|
|
710
|
+
content = "Tool #{tool_label(name)} could not prepare arguments: its argument #{phase} " \
|
|
711
|
+
"failed (#{utf8_prefix(klass, 120)}). The tool did not run. This is a host " \
|
|
712
|
+
"configuration failure; do not retry the same call unchanged."
|
|
713
|
+
ToolResult.new(content: utf8_prefix(content, MAX_ARGUMENT_ERROR_BYTES), error: true)
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def compile_tool_contracts
|
|
717
|
+
validators = {}
|
|
718
|
+
specs = @tools.map do |tool|
|
|
719
|
+
raw = tool.spec
|
|
720
|
+
unless raw.is_a?(Hash)
|
|
721
|
+
raise ConfigurationError, "tool #{tool.name.inspect} spec must be a Hash"
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
spec = raw.transform_keys(&:to_sym)
|
|
725
|
+
schema = spec.fetch(:input_schema) do
|
|
726
|
+
raise ConfigurationError, "tool #{tool.name.inspect} spec needs input_schema"
|
|
727
|
+
end
|
|
728
|
+
if tool.is_a?(Tool)
|
|
729
|
+
schema = tool.input_schema
|
|
730
|
+
else
|
|
731
|
+
complete = tool.respond_to?(:prepared_argument_violations)
|
|
732
|
+
plan = Schema.tool_validator(schema, complete:)
|
|
733
|
+
validators[tool.name] = plan
|
|
734
|
+
schema = plan.schema
|
|
735
|
+
end
|
|
736
|
+
spec.merge(input_schema: schema).freeze
|
|
737
|
+
end
|
|
738
|
+
[specs.freeze, validators.freeze]
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
def tool_label(name)
|
|
742
|
+
safe = safe_violation(name)
|
|
743
|
+
JSON.generate(utf8_prefix(safe, 120))
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
def safe_violation(violation)
|
|
747
|
+
raw = violation.to_s
|
|
748
|
+
prefix = raw.byteslice(0, MAX_VIOLATION_SOURCE_BYTES).dup.force_encoding(raw.encoding)
|
|
749
|
+
prefix.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
|
|
750
|
+
.gsub(/[[:space:]]+/, " ").strip
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
def utf8_prefix(text, bytes)
|
|
754
|
+
return text if text.bytesize <= bytes
|
|
755
|
+
return "" if bytes <= 3
|
|
756
|
+
|
|
757
|
+
prefix = text.byteslice(0, bytes - 3).dup.force_encoding(Encoding::UTF_8)
|
|
758
|
+
prefix = prefix.byteslice(0, prefix.bytesize - 1) until prefix.valid_encoding?
|
|
759
|
+
"#{prefix}..."
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
# Runs calls without persisting them; the caller commits and rewrites
|
|
763
|
+
# settled results in the model's original call order.
|
|
290
764
|
def execute(calls, signal, &emit)
|
|
291
|
-
return if calls.empty?
|
|
765
|
+
return [] if calls.empty?
|
|
292
766
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
767
|
+
ToolExecutor.call_with_outcomes(calls, @tools_by_name, signal: signal,
|
|
768
|
+
max_concurrency: @max_concurrency,
|
|
769
|
+
session: @session, emit: emit,
|
|
770
|
+
app: @context,
|
|
771
|
+
prepared_arguments: true)
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
def interrupt_tool_turn(calls, signal, &)
|
|
775
|
+
answer_results(calls, interrupted_results(calls), executed: [], signal:, &)
|
|
776
|
+
[[], false]
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
def interrupted_results(calls)
|
|
780
|
+
calls.map do |call|
|
|
781
|
+
result = ToolResult.new(content: ToolExecutor::INTERRUPTED, error: true)
|
|
782
|
+
[call, result, nil]
|
|
301
783
|
end
|
|
302
784
|
end
|
|
303
785
|
|
|
@@ -305,41 +787,88 @@ module Mistri
|
|
|
305
787
|
# reacts; a hook that raises blocks conservatively rather than letting
|
|
306
788
|
# an unpoliced call through.
|
|
307
789
|
def screen(calls, signal, &emit)
|
|
308
|
-
return calls unless @before_tool
|
|
790
|
+
return [calls, []] unless @before_tool
|
|
309
791
|
|
|
310
792
|
context = hook_context(signal, emit)
|
|
311
|
-
calls.
|
|
793
|
+
calls.each_with_object([[], []]) do |call, (cleared, blocked)|
|
|
794
|
+
if signal&.aborted?
|
|
795
|
+
cleared << call
|
|
796
|
+
next
|
|
797
|
+
end
|
|
798
|
+
|
|
312
799
|
reason = begin
|
|
313
800
|
@before_tool.call(call, context)
|
|
801
|
+
rescue EventDelivery::Failure => e
|
|
802
|
+
raise EventDelivery.unwrap(e, context.emit)
|
|
314
803
|
rescue StandardError => e
|
|
315
804
|
"the before_tool hook failed: #{e.class}: #{e.message}"
|
|
316
805
|
end
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
806
|
+
if reason.is_a?(String)
|
|
807
|
+
result = ToolResult.new(content: "Blocked: #{reason}", error: true)
|
|
808
|
+
blocked << [call, result, nil]
|
|
809
|
+
else
|
|
810
|
+
cleared << call
|
|
811
|
+
end
|
|
321
812
|
end
|
|
322
813
|
end
|
|
323
814
|
|
|
324
815
|
def rewrite(call, result, context)
|
|
325
|
-
@after_tool.call(call, result, context) || result
|
|
816
|
+
rewritten = @after_tool.call(call, result, context) || result
|
|
817
|
+
return rewritten unless result.is_a?(ToolResult) && result.error?
|
|
818
|
+
|
|
819
|
+
if rewritten.is_a?(ToolResult)
|
|
820
|
+
rewritten.with(error: true)
|
|
821
|
+
else
|
|
822
|
+
ToolResult.new(content: rewritten, error: true)
|
|
823
|
+
end
|
|
824
|
+
rescue EventDelivery::Failure => e
|
|
825
|
+
raise EventDelivery.unwrap(e, context.emit)
|
|
326
826
|
rescue StandardError => e
|
|
327
|
-
|
|
827
|
+
ToolResult.new(
|
|
828
|
+
content: "Error in after_tool hook: #{e.class}: #{e.message}. The tool already returned; " \
|
|
829
|
+
"verify its effects before retrying.",
|
|
830
|
+
error: true
|
|
831
|
+
)
|
|
328
832
|
end
|
|
329
833
|
|
|
330
834
|
def hook_context(signal, emit)
|
|
331
|
-
ToolContext.new(session: @session, signal: signal, emit: emit,
|
|
835
|
+
ToolContext.new(session: @session, signal: signal, emit: EventDelivery.wrap(emit),
|
|
836
|
+
app: @context)
|
|
332
837
|
end
|
|
333
838
|
|
|
334
839
|
# The tool message carries both channels; the :tool_result event exposes
|
|
335
840
|
# it whole so hosts read event.message.ui for their side of the result.
|
|
336
841
|
def answer(call, result, duration: nil, &emit)
|
|
337
|
-
content, ui = result.is_a?(ToolResult)
|
|
842
|
+
content, ui, tool_error = if result.is_a?(ToolResult)
|
|
843
|
+
[result.content, result.ui, result.error?]
|
|
844
|
+
else
|
|
845
|
+
[result, nil, false]
|
|
846
|
+
end
|
|
338
847
|
message = @session.append_message(Message.tool(content: content, tool_call_id: call.id,
|
|
339
|
-
tool_name: call.name, ui: ui
|
|
848
|
+
tool_name: call.name, ui: ui,
|
|
849
|
+
tool_error: tool_error))
|
|
340
850
|
text = content.is_a?(String) ? content : "[content]"
|
|
341
851
|
emit&.call(Event.new(type: :tool_result, tool_call: call, content: text,
|
|
342
|
-
message: message, duration: duration))
|
|
852
|
+
message: message, duration: duration, tool_error: tool_error))
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
def answer_results(calls, results, executed:, signal:, &emit)
|
|
856
|
+
executed_results = if @after_tool
|
|
857
|
+
{}.compare_by_identity.tap do |index|
|
|
858
|
+
executed.each { |result| index[result] = true if result[3] }
|
|
859
|
+
end
|
|
860
|
+
end
|
|
861
|
+
by_call = {}.compare_by_identity
|
|
862
|
+
results.each { |result| (by_call[result[0]] ||= []) << result }
|
|
863
|
+
context = hook_context(signal, emit) if @after_tool
|
|
864
|
+
calls.each do |call|
|
|
865
|
+
queued = by_call[call]
|
|
866
|
+
next unless queued && (entry = queued.shift)
|
|
867
|
+
|
|
868
|
+
_call, result, seconds = entry
|
|
869
|
+
result = rewrite(call, result, context) if executed_results&.key?(entry)
|
|
870
|
+
answer(call, result, duration: seconds, &emit)
|
|
871
|
+
end
|
|
343
872
|
end
|
|
344
873
|
|
|
345
874
|
def gated?(call)
|
|
@@ -347,10 +876,37 @@ module Mistri
|
|
|
347
876
|
tool ? tool.needs_approval?(call.arguments) : false
|
|
348
877
|
end
|
|
349
878
|
|
|
350
|
-
def
|
|
879
|
+
def partition_approval(calls, signal = nil)
|
|
880
|
+
calls.each_with_object([[], [], []]) do |call, (parked, free, rejected)|
|
|
881
|
+
if signal&.aborted?
|
|
882
|
+
free << call
|
|
883
|
+
next
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
(gated?(call) ? parked : free) << call
|
|
887
|
+
rescue StandardError => e
|
|
888
|
+
content = "Error evaluating approval policy for tool #{call.name.inspect}: " \
|
|
889
|
+
"#{e.class}: #{e.message}. The tool did not run."
|
|
890
|
+
rejected << [call, ToolResult.new(content:, error: true), nil]
|
|
891
|
+
end
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
def ends_turn?(call)
|
|
895
|
+
tool = @tools_by_name[call.name]
|
|
896
|
+
tool ? tool.ends_turn? : false
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
# A run stopped during its tool phase ends with a clean assistant
|
|
900
|
+
# message, so the message's stop reason alone would read :completed;
|
|
901
|
+
# the signal is what knows the user stopped it. handed_off marks a run
|
|
902
|
+
# an ends_turn tool ended, and only a genuinely completed one: a run
|
|
903
|
+
# aborted during that same tool phase handed nothing to anyone.
|
|
904
|
+
def finished(message, usage, signal = nil, handed_off: false)
|
|
351
905
|
status = { StopReason::ABORTED => :aborted, StopReason::BUDGET => :budget,
|
|
352
906
|
StopReason::ERROR => :error }.fetch(message.stop_reason, :completed)
|
|
353
|
-
|
|
907
|
+
status = :aborted if status == :completed && signal&.aborted?
|
|
908
|
+
Result.new(message: message, status: status, usage: usage,
|
|
909
|
+
handed_off: handed_off && status == :completed)
|
|
354
910
|
end
|
|
355
911
|
|
|
356
912
|
def suspended(message, parked, usage)
|
|
@@ -367,35 +923,6 @@ module Mistri
|
|
|
367
923
|
Result.new(message: message, status: :budget, usage: usage)
|
|
368
924
|
end
|
|
369
925
|
|
|
370
|
-
# Distinguishable from a parsed nil: JSON "null" is a valid value.
|
|
371
|
-
PARSE_FAILED = Object.new.freeze
|
|
372
|
-
private_constant :PARSE_FAILED
|
|
373
|
-
|
|
374
|
-
def task_input(input, schema)
|
|
375
|
-
"#{input}\n\nAnswer with ONLY a JSON value matching this schema:\n" \
|
|
376
|
-
"#{JSON.generate(Schema.strict(schema))}"
|
|
377
|
-
end
|
|
378
|
-
|
|
379
|
-
def parse_output(text)
|
|
380
|
-
body = text.to_s.strip
|
|
381
|
-
body = body[/\A```(?:json)?\s*(.*?)```\z/m, 1] || body
|
|
382
|
-
JSON.parse(body)
|
|
383
|
-
rescue JSON::ParserError
|
|
384
|
-
PARSE_FAILED
|
|
385
|
-
end
|
|
386
|
-
|
|
387
|
-
def task_errors(value, schema)
|
|
388
|
-
return ["the answer is not valid JSON"] if value.equal?(PARSE_FAILED)
|
|
389
|
-
|
|
390
|
-
Schema.violations(value, schema)
|
|
391
|
-
end
|
|
392
|
-
|
|
393
|
-
def fix_prompt(errors)
|
|
394
|
-
lines = errors.map { |error| "- #{error}" }.join("\n")
|
|
395
|
-
"Your answer did not satisfy the required output schema. Problems:\n" \
|
|
396
|
-
"#{lines}\nReply with ONLY the corrected JSON."
|
|
397
|
-
end
|
|
398
|
-
|
|
399
926
|
def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
400
|
-
end
|
|
927
|
+
end # rubocop:enable Metrics/ClassLength
|
|
401
928
|
end
|