mistri 0.0.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +215 -0
  3. data/README.md +367 -3
  4. data/lib/generators/mistri/install/install_generator.rb +54 -0
  5. data/lib/generators/mistri/install/templates/migration.rb.tt +14 -0
  6. data/lib/generators/mistri/install/templates/model.rb.tt +4 -0
  7. data/lib/generators/mistri/mcp/mcp_generator.rb +57 -0
  8. data/lib/generators/mistri/mcp/templates/migration.rb.tt +27 -0
  9. data/lib/generators/mistri/mcp/templates/model.rb.tt +63 -0
  10. data/lib/mistri/abort_signal.rb +63 -0
  11. data/lib/mistri/agent.rb +389 -0
  12. data/lib/mistri/budget.rb +29 -0
  13. data/lib/mistri/compaction.rb +78 -0
  14. data/lib/mistri/compactor.rb +182 -0
  15. data/lib/mistri/content.rb +89 -0
  16. data/lib/mistri/edit.rb +238 -0
  17. data/lib/mistri/errors.rb +94 -0
  18. data/lib/mistri/event.rb +54 -0
  19. data/lib/mistri/mcp/client.rb +156 -0
  20. data/lib/mistri/mcp/oauth.rb +286 -0
  21. data/lib/mistri/mcp/wires.rb +164 -0
  22. data/lib/mistri/mcp.rb +96 -0
  23. data/lib/mistri/memory.rb +26 -0
  24. data/lib/mistri/message.rb +90 -0
  25. data/lib/mistri/models.rb +43 -0
  26. data/lib/mistri/partial_json.rb +210 -0
  27. data/lib/mistri/providers/anthropic/assembler.rb +205 -0
  28. data/lib/mistri/providers/anthropic/serializer.rb +106 -0
  29. data/lib/mistri/providers/anthropic.rb +106 -0
  30. data/lib/mistri/providers/fake.rb +109 -0
  31. data/lib/mistri/providers/gemini/assembler.rb +163 -0
  32. data/lib/mistri/providers/gemini/serializer.rb +109 -0
  33. data/lib/mistri/providers/gemini.rb +73 -0
  34. data/lib/mistri/providers/openai/assembler.rb +205 -0
  35. data/lib/mistri/providers/openai/serializer.rb +104 -0
  36. data/lib/mistri/providers/openai.rb +72 -0
  37. data/lib/mistri/reminder.rb +36 -0
  38. data/lib/mistri/result.rb +32 -0
  39. data/lib/mistri/retry_policy.rb +47 -0
  40. data/lib/mistri/schema.rb +162 -0
  41. data/lib/mistri/session.rb +124 -0
  42. data/lib/mistri/sinks/action_cable.rb +30 -0
  43. data/lib/mistri/sinks/coalesced.rb +61 -0
  44. data/lib/mistri/sinks/sse.rb +26 -0
  45. data/lib/mistri/skill.rb +15 -0
  46. data/lib/mistri/skills.rb +81 -0
  47. data/lib/mistri/sse.rb +50 -0
  48. data/lib/mistri/stop_reason.rb +25 -0
  49. data/lib/mistri/stores/active_record.rb +47 -0
  50. data/lib/mistri/stores/jsonl.rb +37 -0
  51. data/lib/mistri/stores/memory.rb +22 -0
  52. data/lib/mistri/sub_agent.rb +211 -0
  53. data/lib/mistri/tool.rb +95 -0
  54. data/lib/mistri/tool_call.rb +18 -0
  55. data/lib/mistri/tool_context.rb +15 -0
  56. data/lib/mistri/tool_executor.rb +87 -0
  57. data/lib/mistri/tool_result.rb +23 -0
  58. data/lib/mistri/tools/edit_file.rb +37 -0
  59. data/lib/mistri/tools/find_in_file.rb +36 -0
  60. data/lib/mistri/tools/list_files.rb +16 -0
  61. data/lib/mistri/tools/read_file.rb +38 -0
  62. data/lib/mistri/tools/read_memory.rb +16 -0
  63. data/lib/mistri/tools/update_memory.rb +22 -0
  64. data/lib/mistri/tools/write_file.rb +20 -0
  65. data/lib/mistri/tools.rb +50 -0
  66. data/lib/mistri/transport.rb +228 -0
  67. data/lib/mistri/usage.rb +79 -0
  68. data/lib/mistri/version.rb +1 -1
  69. data/lib/mistri/workspace/active_record.rb +47 -0
  70. data/lib/mistri/workspace/directory.rb +52 -0
  71. data/lib/mistri/workspace/memory.rb +40 -0
  72. data/lib/mistri/workspace/single.rb +48 -0
  73. data/lib/mistri.rb +89 -0
  74. metadata +79 -10
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/named_base"
5
+ require "rails/generators/active_record"
6
+
7
+ module Mistri
8
+ module Generators
9
+ # bin/rails generate mistri:mcp McpConnection
10
+ #
11
+ # Creates a host-named MCP connection model and migration: each row is
12
+ # one server connection and carries its own OAuth flow state, so the
13
+ # connect/callback pair works from a controller, a GraphQL mutation, or
14
+ # anywhere else the host prefers.
15
+ class McpGenerator < Rails::Generators::NamedBase
16
+ include ActiveRecord::Generators::Migration
17
+
18
+ source_root File.expand_path("templates", __dir__)
19
+
20
+ argument :name, type: :string, default: "McpConnection"
21
+
22
+ def create_model
23
+ template "model.rb.tt", File.join("app/models", class_path, "#{file_name}.rb")
24
+ end
25
+
26
+ def create_migration_file
27
+ migration_template "migration.rb.tt",
28
+ File.join(db_migrate_path, "create_#{table_name}.rb")
29
+ end
30
+
31
+ def show_wiring
32
+ say <<~NOTE
33
+
34
+ Connect (from a controller, GraphQL mutation, wherever):
35
+
36
+ connection, authorize_url = #{class_name}.connect(
37
+ name: "Linear", url: params[:url],
38
+ client_name: "YourApp", redirect_uri: mcp_callback_url,
39
+ )
40
+ redirect_to authorize_url, allow_other_host: true
41
+
42
+ Callback:
43
+
44
+ connection = #{class_name}.complete(state: params[:state], code: params[:code])
45
+
46
+ Then hand its tools to an agent:
47
+
48
+ agent = Mistri.agent("claude-opus-4-8", tools: connection.tools(prefix: "linear"))
49
+
50
+ Tokens are encrypted; run bin/rails db:encryption:init if you have
51
+ not set up Active Record encryption.
52
+
53
+ NOTE
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,27 @@
1
+ class Create<%= table_name.camelize %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :<%= table_name %> do |t|
4
+ t.string :name, null: false
5
+ t.string :url, null: false
6
+ t.string :status, null: false, default: "pending"
7
+
8
+ # OAuth flow state; cleared once connected.
9
+ t.string :state
10
+ t.string :code_verifier
11
+ t.string :redirect_uri
12
+
13
+ t.string :client_id
14
+ t.string :client_secret
15
+ t.string :token_endpoint
16
+ t.string :token_auth_method
17
+ t.text :access_token
18
+ t.text :refresh_token
19
+ t.datetime :expires_at
20
+ t.string :scope
21
+ t.timestamps
22
+ end
23
+
24
+ # The callback finds its pending row by state.
25
+ add_index :<%= table_name %>, :state, unique: true
26
+ end
27
+ end
@@ -0,0 +1,63 @@
1
+ # One MCP server connection: its own OAuth flow state, its tokens, and the
2
+ # bridge into agent tools. Rows with a manually supplied access_token (API
3
+ # key servers) work the same way without the OAuth columns.
4
+ class <%= class_name %> < ApplicationRecord
5
+ encrypts :access_token, :refresh_token, :client_secret
6
+
7
+ # Begin the OAuth flow: persists a pending connection and returns it with
8
+ # the URL to send the user to.
9
+ def self.connect(name:, url:, client_name:, redirect_uri:, scope: nil)
10
+ flow = Mistri::MCP::OAuth.start(url: url, client_name: client_name,
11
+ redirect_uri: redirect_uri, scope: scope)
12
+ connection = create!(name: name, url: url, status: "pending",
13
+ state: flow["state"], code_verifier: flow["code_verifier"],
14
+ client_id: flow["client_id"], client_secret: flow["client_secret"],
15
+ token_endpoint: flow["token_endpoint"],
16
+ token_auth_method: flow["token_auth_method"],
17
+ redirect_uri: flow["redirect_uri"])
18
+ [connection, flow["authorize_url"]]
19
+ end
20
+
21
+ # Finish the flow from the OAuth callback.
22
+ def self.complete(state:, code:)
23
+ connection = find_by!(state: state)
24
+ tokens = Mistri::MCP::OAuth.complete(code: code,
25
+ code_verifier: connection.code_verifier,
26
+ client_id: connection.client_id,
27
+ client_secret: connection.client_secret,
28
+ token_endpoint: connection.token_endpoint,
29
+ token_auth_method: connection.token_auth_method,
30
+ resource: connection.url,
31
+ redirect_uri: connection.redirect_uri)
32
+ connection.update!(status: "connected", state: nil, code_verifier: nil,
33
+ access_token: tokens["access_token"],
34
+ refresh_token: tokens["refresh_token"],
35
+ expires_at: tokens["expires_at"], scope: tokens["scope"])
36
+ connection
37
+ end
38
+
39
+ def client
40
+ Mistri::MCP::Client.new(url: url, token: -> { bearer_token })
41
+ end
42
+
43
+ def tools(**options)
44
+ Mistri::MCP.tools(client, **options)
45
+ end
46
+
47
+ # A fresh bearer for every request; refreshes ahead of expiry and persists
48
+ # the rotated refresh token.
49
+ def bearer_token
50
+ refresh! if refresh_token.present? && expires_at && expires_at <= 1.minute.from_now
51
+ access_token
52
+ end
53
+
54
+ def refresh!
55
+ tokens = Mistri::MCP::OAuth.refresh(refresh_token: refresh_token,
56
+ client_id: client_id, client_secret: client_secret,
57
+ token_endpoint: token_endpoint,
58
+ token_auth_method: token_auth_method, resource: url)
59
+ update!(access_token: tokens["access_token"],
60
+ refresh_token: tokens["refresh_token"] || refresh_token,
61
+ expires_at: tokens["expires_at"], scope: tokens["scope"] || scope)
62
+ end
63
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # A thread-safe, one-way latch for cancelling a run. The host trips it from
5
+ # any thread; the loop and tools check it cooperatively at safe points, and
6
+ # the transport registers an on-abort callback to close an in-flight socket,
7
+ # so even a stalled read stops immediately instead of waiting out its
8
+ # read timeout.
9
+ class AbortSignal
10
+ def initialize
11
+ @mutex = Mutex.new
12
+ @aborted = false
13
+ @reason = nil
14
+ @callbacks = []
15
+ end
16
+
17
+ def aborted? = @aborted
18
+
19
+ attr_reader :reason
20
+
21
+ # Trip the latch and fire every registered callback exactly once.
22
+ # Subsequent calls are no-ops.
23
+ def abort!(reason = nil)
24
+ callbacks = @mutex.synchronize do
25
+ break [] if @aborted
26
+
27
+ @aborted = true
28
+ @reason = reason
29
+ @callbacks.dup.tap { @callbacks.clear }
30
+ end
31
+ callbacks.each { |callback| safely(callback) }
32
+ nil
33
+ end
34
+
35
+ # Register a callback for the moment of abort. Fires immediately when the
36
+ # signal is already tripped. Returns a handle for #remove_callback.
37
+ def on_abort(&callback)
38
+ fire_now = @mutex.synchronize do
39
+ @callbacks << callback unless @aborted
40
+ @aborted
41
+ end
42
+ safely(callback) if fire_now
43
+ callback
44
+ end
45
+
46
+ # Deregister a callback, so a completed request does not leak its socket
47
+ # closer into a later abort.
48
+ def remove_callback(handle)
49
+ @mutex.synchronize { @callbacks.delete(handle) }
50
+ nil
51
+ end
52
+
53
+ private
54
+
55
+ # An abort must reach every callback; one raising observer cannot be
56
+ # allowed to strand the others.
57
+ def safely(callback)
58
+ callback.call
59
+ rescue StandardError
60
+ nil
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,389 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mistri
6
+ # The agent loop: prompt the provider, run any tools it calls, feed the
7
+ # results back, and repeat until it answers without calling tools. Every
8
+ # streamed event reaches the caller's block as it arrives, and every run
9
+ # returns a Result.
10
+ #
11
+ # Each message persists to the session the moment it completes, so a crash
12
+ # or an abort leaves a replay-valid transcript with no repair step. A tool
13
+ # marked needs_approval suspends the run instead of executing: the run
14
+ # returns at once (no thread ever waits on a human), the decision arrives
15
+ # later as a session entry from any process, and resume settles it and
16
+ # carries on. Session#steer queues a user message from any process while
17
+ # the loop runs; it folds into the transcript at the next turn boundary.
18
+ class Agent
19
+ # compaction defaults on so long sessions survive their context window;
20
+ # pass false to disable, or a tuned Compaction. It only ever triggers
21
+ # when the model's window is known (catalog or Compaction#window).
22
+ # skills: an array of Skill (or a directory path for Skills.load). Their
23
+ # descriptions join the system prompt and a read_skill tool serves full
24
+ # bodies on demand.
25
+ # before_tool and after_tool are the programmatic gates around every
26
+ # execution: before_tool(call, context) blocks a call by returning the
27
+ # reason as a String, which answers the model in band (and it runs again
28
+ # when an approved call finally executes, so a decision that aged days
29
+ # still passes current policy); after_tool(call, result, context) may
30
+ # return a replacement result, or nil to keep the original.
31
+ def initialize(provider:, session: nil, system: nil, tools: [], budget: nil,
32
+ max_concurrency: 4, transform_context: nil, compaction: Compaction.new,
33
+ retries: RetryPolicy.new, skills: [], before_tool: nil, after_tool: nil)
34
+ @provider = provider
35
+ @session = session || Session.new(store: Stores::Memory.new)
36
+ skills = skills.is_a?(String) ? Skills.load(skills) : Array(skills)
37
+ @system = Skills.amend(system, skills)
38
+ @tools = skills.empty? ? tools : tools + [Skills.reader(skills)]
39
+ @tools_by_name = @tools.to_h { |tool| [tool.name, tool] }
40
+ raise ConfigurationError, "duplicate tool names" if @tools_by_name.length != @tools.length
41
+
42
+ @budget = budget || Budget.new
43
+ @max_concurrency = max_concurrency
44
+ @transform_context = Array(transform_context)
45
+ @compaction = compaction || nil
46
+ @retries = retries || nil
47
+ @before_tool = before_tool
48
+ @after_tool = after_tool
49
+ end
50
+
51
+ attr_reader :session
52
+
53
+ # Run one exchange: append the user turn, then loop until the model
54
+ # answers without tools, a gated tool suspends the run, the run aborts,
55
+ # or a budget stops it.
56
+ # output_schema constrains every non-tool answer to JSON matching the
57
+ # schema, natively where the provider supports it. task adds validation
58
+ # on top; run alone does not validate.
59
+ def run(input, images: [], signal: nil, output_schema: nil, &emit)
60
+ if @session.open_approvals.any?
61
+ raise ConfigurationError, "session is awaiting approval decisions; call resume"
62
+ end
63
+ if input.to_s.empty? && Array(images).empty?
64
+ raise ArgumentError, "run needs input text or images"
65
+ end
66
+
67
+ fold_steers # steers queued while idle arrived first; keep that order
68
+ @session.append_message(Message.user_with_images(input, images))
69
+ loop_turns(signal, output_schema, &emit)
70
+ end
71
+
72
+ # Continue a suspended run. Undecided approvals return immediately, still
73
+ # suspended. Decided ones settle first: approved calls execute, denied
74
+ # calls answer in band so the model knows and can react. Then the loop
75
+ # carries on as if it never stopped.
76
+ def resume(signal: nil, &emit)
77
+ open = @session.open_approvals
78
+ pending = open.select { |approval| approval[:decision].nil? }
79
+ if pending.any?
80
+ return Result.new(message: nil, status: :awaiting_approval,
81
+ pending: pending.map { |approval| approval[:call] },
82
+ usage: Usage.zero)
83
+ end
84
+
85
+ settle(open, signal, &emit)
86
+ loop_turns(signal, nil, &emit)
87
+ end
88
+
89
+ # Run an exchange that must end in a JSON value matching schema. Tools
90
+ # run as usual; providers constrain the final answer natively where they
91
+ # can, and the answer is validated here regardless. A violation goes
92
+ # back to the model (fixes more times), then raises SchemaError. The
93
+ # Result carries the validated value as output.
94
+ #
95
+ # A run that suspends for approval returns as-is: validation applies to
96
+ # completed runs only, so resume the session and re-ask if that happens
97
+ # mid-task.
98
+ def task(input, schema:, images: [], signal: nil, fixes: 1, &emit)
99
+ result = run(task_input(input, schema), images: images, signal: signal,
100
+ output_schema: schema, &emit)
101
+ spent = result.usage
102
+ fixes.downto(0) do |remaining|
103
+ result = result.with(usage: spent)
104
+ return result unless result.completed?
105
+
106
+ value = parse_output(result.text)
107
+ errors = task_errors(value, schema)
108
+ return result.with(output: value) if errors.empty?
109
+ raise SchemaError, "task output failed validation: #{errors.join("; ")}" if remaining.zero?
110
+
111
+ result = run(fix_prompt(errors), signal: signal, output_schema: schema, &emit)
112
+ spent += result.usage
113
+ end
114
+ end
115
+
116
+ # How full the context is: {tokens:, window:, fraction:}. Hosts render
117
+ # meters and near-limit warnings from this; window is nil for models the
118
+ # catalog does not know unless Compaction#window supplies one.
119
+ def context_usage
120
+ tokens = Compaction.context_tokens(@session.messages)
121
+ window = context_window
122
+ { tokens: tokens, window: window,
123
+ fraction: window && (tokens.to_f / window).round(3) }
124
+ end
125
+
126
+ # Compact now (a UI button, a pre-flight trim before a big task). Returns
127
+ # the Compactor result, or nil when there is nothing worth compacting.
128
+ def compact(&)
129
+ Compactor.call(session: @session, provider: @provider,
130
+ settings: @compaction || Compaction.new, &)
131
+ end
132
+
133
+ private
134
+
135
+ def loop_turns(signal, output_schema = nil, &emit)
136
+ turns = 0
137
+ usage = Usage.zero
138
+ started = monotonic_now
139
+ loop do
140
+ reason = @budget.exceeded(turns: turns, usage: usage, elapsed: monotonic_now - started)
141
+ return stop_for_budget(reason, usage, &emit) if reason
142
+
143
+ fold_steers
144
+ compacted = auto_compact(&emit)
145
+ usage += compacted[:usage] if compacted&.dig(:usage)
146
+ last = run_turn(signal, output_schema, &emit)
147
+ turns += 1
148
+ usage += last.usage if last.usage
149
+
150
+ # Any tool call the turn made must be answered or parked, or the
151
+ # transcript is unpairable and replay fails.
152
+ parked = last.tool_calls? ? run_tools(last, signal, &emit) : []
153
+ return suspended(last, parked, usage) if parked.any?
154
+ return finished(last, usage) if done?(last, signal)
155
+ end
156
+ end
157
+
158
+ # A steer that lands while the model finishes cleanly extends the run one
159
+ # more turn so it gets answered. Aborts, errors, and length stops always
160
+ # end the run; the steer stays pending for the next one.
161
+ def done?(last, signal)
162
+ return false if last.stop_reason == StopReason::TOOL_USE && !signal&.aborted?
163
+ return true if signal&.aborted? || last.stop_reason != StopReason::STOP
164
+
165
+ @session.pending_steers.empty?
166
+ end
167
+
168
+ # Compact when the context has grown into the reserve. A failed
169
+ # summarization skips quietly here: if the context genuinely no longer
170
+ # fits, the next turn surfaces the real provider error.
171
+ def auto_compact(&)
172
+ return nil unless @compaction
173
+
174
+ tokens = Compaction.context_tokens(@session.messages)
175
+ return nil unless @compaction.needed?(tokens, context_window)
176
+
177
+ Compactor.call(session: @session, provider: @provider, settings: @compaction, &)
178
+ rescue CompactionError
179
+ nil
180
+ end
181
+
182
+ def context_window
183
+ @compaction&.window || Models.find(@provider.model)&.context_window
184
+ end
185
+
186
+ # Materialize queued steers into the transcript in arrival order. The
187
+ # folded message entry carries the steer id, which is what marks the steer
188
+ # consumed: one append is both the fold and the marker, so a crash between
189
+ # steers never double-delivers.
190
+ def fold_steers
191
+ @session.pending_steers.each do |steer|
192
+ @session.append("message", "message" => steer["message"], "steer_id" => steer["id"])
193
+ end
194
+ end
195
+
196
+ # transform_context reshapes what the model sees each turn (reminders,
197
+ # redaction, windowing) without touching what the session stores. The
198
+ # lambda gets the replay messages and returns the messages to send; it
199
+ # must keep every tool call paired with its result or providers reject
200
+ # the request.
201
+ #
202
+ # A transient failure retries the same request with backoff; the failed
203
+ # attempt is recorded as a retry entry, never as a message, so retries
204
+ # stay invisible to the model. Only the final outcome persists.
205
+ def run_turn(signal, output_schema = nil, &emit)
206
+ history = @transform_context.reduce(@session.messages) do |messages, transform|
207
+ transform.call(messages)
208
+ end
209
+ attempt = 0
210
+ loop do
211
+ message = @provider.stream(messages: history, system: @system,
212
+ tools: @tools.map(&:spec), signal: signal,
213
+ output_schema: output_schema, &emit)
214
+ attempt += 1
215
+ if retry_turn?(message, attempt, signal)
216
+ pause = @retries.delay(attempt, message.error&.dig("retry_after"))
217
+ record_retry(message, attempt, pause, &emit)
218
+ wait(pause, signal)
219
+ next unless signal&.aborted?
220
+ end
221
+ @session.append_message(message)
222
+ return message
223
+ end
224
+ end
225
+
226
+ def retry_turn?(message, attempt, signal)
227
+ return false unless @retries && message.stop_reason == StopReason::ERROR
228
+ return false if signal&.aborted?
229
+
230
+ @retries.retry?(message.error, attempt)
231
+ end
232
+
233
+ def record_retry(message, attempt, pause, &emit)
234
+ @session.append("retry", "attempt" => attempt, "error" => message.error,
235
+ "delay" => pause.round(2))
236
+ note = format("attempt %<attempt>d failed; retrying in %<pause>.1fs",
237
+ attempt: attempt, pause: pause)
238
+ emit&.call(Event.new(type: :retry, content: note, reason: StopReason::ERROR,
239
+ message: message))
240
+ end
241
+
242
+ # Backoff that an abort can cut short.
243
+ def wait(seconds, signal)
244
+ deadline = monotonic_now + seconds
245
+ sleep(0.1) while monotonic_now < deadline && !signal&.aborted?
246
+ end
247
+
248
+ # Answer or park the assistant's tool calls. Ungated calls execute (only
249
+ # on a genuine tool_use turn with no abort; otherwise they pair with
250
+ # interrupted results). Gated calls park as approval requests and are
251
+ # returned, so the loop can suspend. Nothing is left dangling either way.
252
+ def run_tools(assistant, signal, &emit)
253
+ calls = assistant.tool_calls
254
+ unless assistant.stop_reason == StopReason::TOOL_USE && !signal&.aborted?
255
+ calls.each { |call| answer(call, ToolExecutor::INTERRUPTED, &emit) }
256
+ return []
257
+ end
258
+
259
+ parked, free = screen(calls, signal, &emit).partition { |call| gated?(call) }
260
+ execute(free, signal, &emit)
261
+ parked.each do |call|
262
+ @session.append("approval_request", "call" => call.to_h)
263
+ emit&.call(Event.new(type: :approval_needed, tool_call: call))
264
+ end
265
+ parked
266
+ end
267
+
268
+ def settle(open, signal, &emit)
269
+ approved, denied = open.partition { |approval| approval[:decision]["approved"] }
270
+ cleared = screen(approved.map { |approval| approval[:call] }, signal, &emit)
271
+ execute(cleared, signal, &emit)
272
+ denied.each do |approval|
273
+ note = approval[:decision]["note"]
274
+ text = "The user denied this tool call#{note ? ": #{note}" : "."}"
275
+ answer(approval[:call], text, &emit)
276
+ end
277
+ end
278
+
279
+ def execute(calls, signal, &emit)
280
+ return if calls.empty?
281
+
282
+ results = ToolExecutor.call(calls, @tools_by_name, signal: signal,
283
+ max_concurrency: @max_concurrency,
284
+ session: @session, emit: emit)
285
+ context = hook_context(signal, emit)
286
+ results.each do |call, result, seconds|
287
+ result = rewrite(call, result, context) if @after_tool
288
+ answer(call, result, duration: seconds, &emit)
289
+ end
290
+ end
291
+
292
+ # A blocked call answers in band, so the model reads the reason and
293
+ # reacts; a hook that raises blocks conservatively rather than letting
294
+ # an unpoliced call through.
295
+ def screen(calls, signal, &emit)
296
+ return calls unless @before_tool
297
+
298
+ context = hook_context(signal, emit)
299
+ calls.reject do |call|
300
+ reason = begin
301
+ @before_tool.call(call, context)
302
+ rescue StandardError => e
303
+ "the before_tool hook failed: #{e.class}: #{e.message}"
304
+ end
305
+ next false unless reason.is_a?(String)
306
+
307
+ answer(call, "Blocked: #{reason}", &emit)
308
+ true
309
+ end
310
+ end
311
+
312
+ def rewrite(call, result, context)
313
+ @after_tool.call(call, result, context) || result
314
+ rescue StandardError => e
315
+ "Error in after_tool hook: #{e.class}: #{e.message}"
316
+ end
317
+
318
+ def hook_context(signal, emit)
319
+ ToolContext.new(session: @session, signal: signal, emit: emit)
320
+ end
321
+
322
+ # The tool message carries both channels; the :tool_result event exposes
323
+ # it whole so hosts read event.message.ui for their side of the result.
324
+ def answer(call, result, duration: nil, &emit)
325
+ content, ui = result.is_a?(ToolResult) ? [result.content, result.ui] : [result, nil]
326
+ message = @session.append_message(Message.tool(content: content, tool_call_id: call.id,
327
+ tool_name: call.name, ui: ui))
328
+ text = content.is_a?(String) ? content : "[content]"
329
+ emit&.call(Event.new(type: :tool_result, tool_call: call, content: text,
330
+ message: message, duration: duration))
331
+ end
332
+
333
+ def gated?(call)
334
+ tool = @tools_by_name[call.name]
335
+ tool ? tool.needs_approval?(call.arguments) : false
336
+ end
337
+
338
+ def finished(message, usage)
339
+ status = { StopReason::ABORTED => :aborted, StopReason::BUDGET => :budget,
340
+ StopReason::ERROR => :error }.fetch(message.stop_reason, :completed)
341
+ Result.new(message: message, status: status, usage: usage)
342
+ end
343
+
344
+ def suspended(message, parked, usage)
345
+ Result.new(message: message, status: :awaiting_approval, pending: parked, usage: usage)
346
+ end
347
+
348
+ def stop_for_budget(reason, usage, &emit)
349
+ message = Message.assistant(content: "Run stopped: #{reason} budget reached.",
350
+ stop_reason: StopReason::BUDGET,
351
+ error_message: "budget_#{reason}")
352
+ @session.append_message(message)
353
+ emit&.call(Event.new(type: :error, reason: StopReason::BUDGET, message: message,
354
+ error_message: "budget_#{reason}"))
355
+ Result.new(message: message, status: :budget, usage: usage)
356
+ end
357
+
358
+ # Distinguishable from a parsed nil: JSON "null" is a valid value.
359
+ PARSE_FAILED = Object.new.freeze
360
+ private_constant :PARSE_FAILED
361
+
362
+ def task_input(input, schema)
363
+ "#{input}\n\nAnswer with ONLY a JSON value matching this schema:\n" \
364
+ "#{JSON.generate(Schema.strict(schema))}"
365
+ end
366
+
367
+ def parse_output(text)
368
+ body = text.to_s.strip
369
+ body = body[/\A```(?:json)?\s*(.*?)```\z/m, 1] || body
370
+ JSON.parse(body)
371
+ rescue JSON::ParserError
372
+ PARSE_FAILED
373
+ end
374
+
375
+ def task_errors(value, schema)
376
+ return ["the answer is not valid JSON"] if value.equal?(PARSE_FAILED)
377
+
378
+ Schema.violations(value, schema)
379
+ end
380
+
381
+ def fix_prompt(errors)
382
+ lines = errors.map { |error| "- #{error}" }.join("\n")
383
+ "Your answer did not satisfy the required output schema. Problems:\n" \
384
+ "#{lines}\nReply with ONLY the corrected JSON."
385
+ end
386
+
387
+ def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
388
+ end
389
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # Optional per-run ceilings: turns, tokens, dollars, wall-clock seconds.
5
+ # Nothing is enforced unless the host sets it; an empty budget never stops a
6
+ # run. Pure limits with no clock of its own, so one Budget shared across
7
+ # agents or runs behaves identically for each: the loop measures and asks
8
+ # between turns, and a run always finishes the turn it is in.
9
+ class Budget
10
+ def initialize(turns: nil, tokens: nil, cost_usd: nil, wall_clock: nil)
11
+ @turns = turns
12
+ @tokens = tokens
13
+ @cost_usd = cost_usd
14
+ @wall_clock = wall_clock
15
+ end
16
+
17
+ def none? = [@turns, @tokens, @cost_usd, @wall_clock].all?(&:nil?)
18
+
19
+ # The reason the run should stop, or nil to continue.
20
+ def exceeded(turns:, usage:, elapsed: 0)
21
+ return :turns if @turns && turns >= @turns
22
+ return :tokens if @tokens && usage.total_tokens >= @tokens
23
+ return :cost if @cost_usd && usage.cost.total >= @cost_usd
24
+ return :wall_clock if @wall_clock && elapsed >= @wall_clock
25
+
26
+ nil
27
+ end
28
+ end
29
+ end