mistri 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b5526cf23888f4a5872797ce5a6ebe245c0ff1a857ba8bc717681930f8dd4b8
4
- data.tar.gz: fa3f6dee2638cbd1e9c4b60378c40b4ac5a894d3639afff157be6bb70e5fd00f
3
+ metadata.gz: e553e955f2a1263afed1b26889dbf04773916dbd6f33f9229e6db2b729dd655b
4
+ data.tar.gz: 98b95079d9f91b068580ac2708c362b1317452378eba99fe7643e301660ad89d
5
5
  SHA512:
6
- metadata.gz: 8cb353d464264b7d44b8c335cfa938b72136f5273f674d4517632326929216a1a93db9577fb9c75ce66f46a70fc862726ad4444dad6e0a47ba0c8d74f3356ae5
7
- data.tar.gz: e5114ba9c5404ee9698c4878599d9f2a7895e3d9c470e1874b0c377b2ac6b59ef62b541940c5798b5cb769df148f0e5a02d835b78bc75a5655905a2fd4bdfcdb
6
+ metadata.gz: cb690580fe35c2226bfa0d86d23f77c9236e8dcb10ba3af0a7753a1d52b96c11e951d41ee647d0cf947dae1d3c82ca834105070099dec0c4bac728bcd296698b
7
+ data.tar.gz: 9e5be1a7fde00c351abc5f123ddfa82db8cb0ae34f14b0c6aa474b12338c587958c547d68e70280de7cda780ea4e674953e0f28681d33c17b37edd8ea1672d8e
data/CHANGELOG.md CHANGED
@@ -5,6 +5,64 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.2.1] - 2026-07-05
9
+
10
+ - The gem homepage and documentation links now point at
11
+ [mistri.sh](https://mistri.sh), the project site with full docs at
12
+ [mistri.sh/docs](https://mistri.sh/docs/getting-started/).
13
+ - README polish: wordmark, testing section.
14
+
15
+ ## [0.2.0] - 2026-07-05
16
+
17
+ - Repository hygiene: coverage floor enforced in CI (simplecov, 90% line),
18
+ contributing/security/conduct docs, issue and PR templates, Dependabot,
19
+ and rubygems documentation and bug tracker links.
20
+
21
+ - Per-tool timeouts: Tool.define(..., timeout: 30) answers in band when a
22
+ handler stalls, so one hung tool cannot stall the run.
23
+ - :tool_result events carry duration (seconds) for executed tools, feeding
24
+ latency metrics straight from any sink.
25
+
26
+ - Mistri::Reminder.every(3, text): a periodic tail reminder for long runs,
27
+ riding transform_context; due by completed assistant turns, fresh on the
28
+ wire each time, never persisted.
29
+
30
+ - Tool hooks: before_tool(call, context) blocks a call by returning the
31
+ reason as a String, answered to the model in band; it outranks the
32
+ approval gate and screens approved calls again at settle time, so an
33
+ aged approval never beats current policy. after_tool(call, result,
34
+ context) may replace a result (both channels), nil keeps it. Hooks that
35
+ raise fail safe: before blocks, after answers in band.
36
+ - transform_context accepts an array of transforms, applied in order.
37
+
38
+ - Result#usage: every run reports its own token and cost accounting,
39
+ summing persisted turns and compaction calls; task sums across its fix
40
+ passes. Hosts meter a run without walking the session.
41
+
42
+ - MCP stdio wire: Client.new(command: [...], env: {...}) spawns a local
43
+ server as a child process speaking line-delimited JSON-RPC, credentials
44
+ in its environment per spec. Dying servers and non-protocol stdout fail
45
+ loudly; close terminates the child.
46
+
47
+ - MCP connections out of the box: Mistri::MCP::OAuth.start/.complete/
48
+ .refresh are storage-agnostic services implementing the spec's OAuth 2.1
49
+ subset (challenge and well-known discovery, RFC 8414 metadata with an
50
+ OpenID fallback, dynamic client registration as the host application,
51
+ PKCE with resource indicators, rotating refresh). `rails generate
52
+ mistri:mcp YourModel` creates a host-named connection model whose rows
53
+ carry their own flow state and encrypted tokens, with connection.tools
54
+ bridging straight into an agent and refreshing ahead of expiry.
55
+
56
+ - MCP bridge: Mistri::MCP::Client speaks Streamable HTTP (initialize
57
+ handshake, tools/list with pagination, tools/call, sessions with
58
+ transparent expiry recovery, JSON or SSE responses) with zero new
59
+ dependencies. Auth is a headers hash or a token string-or-lambda; a
60
+ lambda re-resolves once on 401, so host refresh logic lives in one place.
61
+ Mistri::MCP.tools bridges any server (or any duck-typed client, the
62
+ official mcp gem included) into Mistri tools with allow/deny lists, name
63
+ prefixing, and per-tool approval gates, so a third-party write tool can
64
+ ride the human-approval arc.
65
+
8
66
  ## [0.1.0] - 2026-07-05
9
67
 
10
68
  - Live integration harness: `rake integration` runs every feature end to
@@ -175,3 +233,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
175
233
  ## [0.0.1] - 2026-07-04
176
234
 
177
235
  - Reserved the gem name.
236
+
237
+ [0.1.0]: https://github.com/mcheemaa/mistri/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,10 +1,16 @@
1
- <h1 align="center">مستری</h1>
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg">
4
+ <img src="assets/logo-light.svg" alt="مستری" width="360">
5
+ </picture>
6
+ </p>
2
7
 
3
8
  <p align="center"><strong>mistri</strong>, the agent harness for Ruby applications.</p>
4
9
 
5
10
  <p align="center">
6
11
  <a href="https://rubygems.org/gems/mistri"><img alt="Gem Version" src="https://img.shields.io/gem/v/mistri"></a>
7
12
  <a href="https://github.com/mcheemaa/mistri/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/mcheemaa/mistri/actions/workflows/ci.yml/badge.svg"></a>
13
+ <a href="https://codecov.io/gh/mcheemaa/mistri"><img alt="Coverage" src="https://codecov.io/gh/mcheemaa/mistri/graph/badge.svg"></a>
8
14
  <a href="mistri.gemspec"><img alt="Ruby >= 3.2" src="https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D"></a>
9
15
  <a href="Gemfile"><img alt="Runtime dependencies: zero" src="https://img.shields.io/badge/runtime_deps-0-brightgreen"></a>
10
16
  <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg"></a>
@@ -250,6 +256,58 @@ The edit engine matches exactly, then whitespace-tolerantly; an ambiguous
250
256
  match refuses (never silently edits the wrong place), and a near-miss error
251
257
  names the closest region so the model's retry is one-shot.
252
258
 
259
+ ## MCP
260
+
261
+ Bridge any Model Context Protocol server's tools into an agent. The client
262
+ speaks Streamable HTTP with zero new dependencies; auth is a token string
263
+ or a lambda that re-resolves once on 401, so refresh logic lives in one
264
+ place. Approval gates compose: a third-party write tool can require a
265
+ human.
266
+
267
+ ```ruby
268
+ client = Mistri::MCP::Client.new(url: "https://mcp.linear.app/mcp",
269
+ token: -> { connection.bearer_token })
270
+ tools = Mistri::MCP.tools(client, prefix: "linear",
271
+ gates: { "create_issue" => true })
272
+
273
+ agent = Mistri.agent("claude-opus-4-8", tools: tools)
274
+ ```
275
+
276
+ Local stdio servers spawn as child processes, credentials in their
277
+ environment. That is also the whole "give the agent a browser" story:
278
+
279
+ ```ruby
280
+ browser = Mistri::MCP::Client.new(
281
+ command: ["npx", "-y", "@playwright/mcp@latest", "--browser", "chrome", "--headless"],
282
+ )
283
+ agent = Mistri.agent("claude-opus-4-8",
284
+ tools: Mistri::MCP.tools(browser, allow: %w[browser_navigate browser_snapshot]))
285
+ ```
286
+
287
+ For the full connect-your-tools story in Rails, generate a connection model
288
+ (name it whatever you like):
289
+
290
+ ```console
291
+ $ bin/rails generate mistri:mcp McpConnection
292
+ ```
293
+
294
+ Each row is one server connection carrying its own OAuth flow state and
295
+ encrypted tokens. The OAuth services underneath (`Mistri::MCP::OAuth.start`,
296
+ `.complete`, `.refresh`) are storage-agnostic, so the same flow works from a
297
+ controller, a GraphQL mutation, or a job. Registration happens as your
298
+ application: `client_name:` is yours to set.
299
+
300
+ ```ruby
301
+ connection, authorize_url = McpConnection.connect(
302
+ name: "Linear", url: params[:url],
303
+ client_name: "YourApp", redirect_uri: mcp_callback_url,
304
+ )
305
+ # redirect the user to authorize_url; then, in the callback:
306
+ connection = McpConnection.complete(state: params[:state], code: params[:code])
307
+
308
+ agent = Mistri.agent("claude-opus-4-8", tools: connection.tools(prefix: "linear"))
309
+ ```
310
+
253
311
  ## Streaming into Rails
254
312
 
255
313
  Sinks bridge the event stream to a transport, and compose as blocks:
@@ -290,7 +348,7 @@ Mistri.agent("gpt-5.5", provider_options: { reasoning: { effort: "high" } })
290
348
  Mistri.agent("claude-opus-4-8", provider_options: { cache: false })
291
349
  ```
292
350
 
293
- ## Verified for real
351
+ ## Testing
294
352
 
295
353
  `rake test` is hermetic and fast. `rake integration` runs every feature end
296
354
  to end against real provider APIs, once per model in the matrix. Scenarios
@@ -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
data/lib/mistri/agent.rb CHANGED
@@ -22,9 +22,15 @@ module Mistri
22
22
  # skills: an array of Skill (or a directory path for Skills.load). Their
23
23
  # descriptions join the system prompt and a read_skill tool serves full
24
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.
25
31
  def initialize(provider:, session: nil, system: nil, tools: [], budget: nil,
26
32
  max_concurrency: 4, transform_context: nil, compaction: Compaction.new,
27
- retries: RetryPolicy.new, skills: [])
33
+ retries: RetryPolicy.new, skills: [], before_tool: nil, after_tool: nil)
28
34
  @provider = provider
29
35
  @session = session || Session.new(store: Stores::Memory.new)
30
36
  skills = skills.is_a?(String) ? Skills.load(skills) : Array(skills)
@@ -35,9 +41,11 @@ module Mistri
35
41
 
36
42
  @budget = budget || Budget.new
37
43
  @max_concurrency = max_concurrency
38
- @transform_context = transform_context
44
+ @transform_context = Array(transform_context)
39
45
  @compaction = compaction || nil
40
46
  @retries = retries || nil
47
+ @before_tool = before_tool
48
+ @after_tool = after_tool
41
49
  end
42
50
 
43
51
  attr_reader :session
@@ -70,7 +78,8 @@ module Mistri
70
78
  pending = open.select { |approval| approval[:decision].nil? }
71
79
  if pending.any?
72
80
  return Result.new(message: nil, status: :awaiting_approval,
73
- pending: pending.map { |approval| approval[:call] })
81
+ pending: pending.map { |approval| approval[:call] },
82
+ usage: Usage.zero)
74
83
  end
75
84
 
76
85
  settle(open, signal, &emit)
@@ -89,7 +98,9 @@ module Mistri
89
98
  def task(input, schema:, images: [], signal: nil, fixes: 1, &emit)
90
99
  result = run(task_input(input, schema), images: images, signal: signal,
91
100
  output_schema: schema, &emit)
101
+ spent = result.usage
92
102
  fixes.downto(0) do |remaining|
103
+ result = result.with(usage: spent)
93
104
  return result unless result.completed?
94
105
 
95
106
  value = parse_output(result.text)
@@ -98,6 +109,7 @@ module Mistri
98
109
  raise SchemaError, "task output failed validation: #{errors.join("; ")}" if remaining.zero?
99
110
 
100
111
  result = run(fix_prompt(errors), signal: signal, output_schema: schema, &emit)
112
+ spent += result.usage
101
113
  end
102
114
  end
103
115
 
@@ -126,7 +138,7 @@ module Mistri
126
138
  started = monotonic_now
127
139
  loop do
128
140
  reason = @budget.exceeded(turns: turns, usage: usage, elapsed: monotonic_now - started)
129
- return stop_for_budget(reason, &emit) if reason
141
+ return stop_for_budget(reason, usage, &emit) if reason
130
142
 
131
143
  fold_steers
132
144
  compacted = auto_compact(&emit)
@@ -138,8 +150,8 @@ module Mistri
138
150
  # Any tool call the turn made must be answered or parked, or the
139
151
  # transcript is unpairable and replay fails.
140
152
  parked = last.tool_calls? ? run_tools(last, signal, &emit) : []
141
- return suspended(last, parked) if parked.any?
142
- return finished(last) if done?(last, signal)
153
+ return suspended(last, parked, usage) if parked.any?
154
+ return finished(last, usage) if done?(last, signal)
143
155
  end
144
156
  end
145
157
 
@@ -191,8 +203,9 @@ module Mistri
191
203
  # attempt is recorded as a retry entry, never as a message, so retries
192
204
  # stay invisible to the model. Only the final outcome persists.
193
205
  def run_turn(signal, output_schema = nil, &emit)
194
- history = @session.messages
195
- history = @transform_context.call(history) if @transform_context
206
+ history = @transform_context.reduce(@session.messages) do |messages, transform|
207
+ transform.call(messages)
208
+ end
196
209
  attempt = 0
197
210
  loop do
198
211
  message = @provider.stream(messages: history, system: @system,
@@ -243,7 +256,7 @@ module Mistri
243
256
  return []
244
257
  end
245
258
 
246
- parked, free = calls.partition { |call| gated?(call) }
259
+ parked, free = screen(calls, signal, &emit).partition { |call| gated?(call) }
247
260
  execute(free, signal, &emit)
248
261
  parked.each do |call|
249
262
  @session.append("approval_request", "call" => call.to_h)
@@ -254,7 +267,8 @@ module Mistri
254
267
 
255
268
  def settle(open, signal, &emit)
256
269
  approved, denied = open.partition { |approval| approval[:decision]["approved"] }
257
- execute(approved.map { |approval| approval[:call] }, signal, &emit)
270
+ cleared = screen(approved.map { |approval| approval[:call] }, signal, &emit)
271
+ execute(cleared, signal, &emit)
258
272
  denied.each do |approval|
259
273
  note = approval[:decision]["note"]
260
274
  text = "The user denied this tool call#{note ? ": #{note}" : "."}"
@@ -268,17 +282,52 @@ module Mistri
268
282
  results = ToolExecutor.call(calls, @tools_by_name, signal: signal,
269
283
  max_concurrency: @max_concurrency,
270
284
  session: @session, emit: emit)
271
- results.each { |call, result| answer(call, result, &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)
272
320
  end
273
321
 
274
322
  # The tool message carries both channels; the :tool_result event exposes
275
323
  # it whole so hosts read event.message.ui for their side of the result.
276
- def answer(call, result, &emit)
324
+ def answer(call, result, duration: nil, &emit)
277
325
  content, ui = result.is_a?(ToolResult) ? [result.content, result.ui] : [result, nil]
278
326
  message = @session.append_message(Message.tool(content: content, tool_call_id: call.id,
279
327
  tool_name: call.name, ui: ui))
280
328
  text = content.is_a?(String) ? content : "[content]"
281
- emit&.call(Event.new(type: :tool_result, tool_call: call, content: text, message: message))
329
+ emit&.call(Event.new(type: :tool_result, tool_call: call, content: text,
330
+ message: message, duration: duration))
282
331
  end
283
332
 
284
333
  def gated?(call)
@@ -286,24 +335,24 @@ module Mistri
286
335
  tool ? tool.needs_approval?(call.arguments) : false
287
336
  end
288
337
 
289
- def finished(message)
338
+ def finished(message, usage)
290
339
  status = { StopReason::ABORTED => :aborted, StopReason::BUDGET => :budget,
291
340
  StopReason::ERROR => :error }.fetch(message.stop_reason, :completed)
292
- Result.new(message: message, status: status)
341
+ Result.new(message: message, status: status, usage: usage)
293
342
  end
294
343
 
295
- def suspended(message, parked)
296
- Result.new(message: message, status: :awaiting_approval, pending: parked)
344
+ def suspended(message, parked, usage)
345
+ Result.new(message: message, status: :awaiting_approval, pending: parked, usage: usage)
297
346
  end
298
347
 
299
- def stop_for_budget(reason, &emit)
348
+ def stop_for_budget(reason, usage, &emit)
300
349
  message = Message.assistant(content: "Run stopped: #{reason} budget reached.",
301
350
  stop_reason: StopReason::BUDGET,
302
351
  error_message: "budget_#{reason}")
303
352
  @session.append_message(message)
304
353
  emit&.call(Event.new(type: :error, reason: StopReason::BUDGET, message: message,
305
354
  error_message: "budget_#{reason}"))
306
- Result.new(message: message, status: :budget)
355
+ Result.new(message: message, status: :budget, usage: usage)
307
356
  end
308
357
 
309
358
  # Distinguishable from a parsed nil: JSON "null" is a valid value.
data/lib/mistri/event.rb CHANGED
@@ -11,8 +11,11 @@ module Mistri
11
11
  # message's content list.
12
12
  # origin names the sub-agent an event came from: nil for this agent's own
13
13
  # turns, and nesting joins names left to right ("researcher>writer").
14
+ # duration is the tool's execution time in seconds on :tool_result
15
+ # events; nil where nothing ran (denials, interruptions).
14
16
  class Event < Data.define(:type, :content_index, :delta, :content, :tool_call,
15
- :reason, :message, :error_message, :partial, :origin)
17
+ :reason, :message, :error_message, :partial, :origin,
18
+ :duration)
16
19
  # The stream types come from a provider mid-turn; the loop adds
17
20
  # :tool_result after it runs each tool, :approval_needed when a gated
18
21
  # call parks for a human, and :compacting/:compaction around a context
@@ -29,7 +32,8 @@ module Mistri
29
32
  ].freeze
30
33
 
31
34
  def initialize(type:, content_index: nil, delta: nil, content: nil, tool_call: nil,
32
- reason: nil, message: nil, error_message: nil, partial: nil, origin: nil)
35
+ reason: nil, message: nil, error_message: nil, partial: nil, origin: nil,
36
+ duration: nil)
33
37
  raise ArgumentError, "unknown event type #{type.inspect}" unless TYPES.include?(type)
34
38
 
35
39
  super
@@ -44,7 +48,7 @@ module Mistri
44
48
  # Partials are ephemeral streaming state and stay out of serialization.
45
49
  def to_h
46
50
  { type:, content_index:, delta:, content:, tool_call: tool_call&.to_h,
47
- reason:, message: message&.to_h, error_message:, origin: }.compact
51
+ reason:, message: message&.to_h, error_message:, origin:, duration: }.compact
48
52
  end
49
53
  end
50
54
  end