mistri 0.1.0 → 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.
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "net/http"
6
+ require "securerandom"
7
+ require "uri"
8
+
9
+ module Mistri
10
+ module MCP
11
+ # The OAuth 2.1 subset the MCP spec requires of clients, as three
12
+ # storage-agnostic services a host calls from anywhere: a controller, a
13
+ # GraphQL mutation, a job. Each returns a string-keyed hash ready to
14
+ # persist on the host's own connection record.
15
+ #
16
+ # flow = Mistri::MCP::OAuth.start(url: params[:url],
17
+ # client_name: "Sendoso",
18
+ # redirect_uri: mcp_callback_url)
19
+ # # persist flow, redirect the user to flow["authorize_url"]
20
+ #
21
+ # tokens = Mistri::MCP::OAuth.complete(code: params[:code], **persisted)
22
+ # tokens = Mistri::MCP::OAuth.refresh(**persisted)
23
+ #
24
+ # Registration happens as the APPLICATION, never as the harness:
25
+ # client_name has no default because that identity is the host's call.
26
+ # Servers without dynamic registration take client_id:/client_secret:
27
+ # directly and skip it.
28
+ module OAuth
29
+ module_function
30
+
31
+ # Discover the server's authorization setup, register the application,
32
+ # and build the authorize URL. Returns everything the callback and
33
+ # refresh need: authorize_url, state, code_verifier, client_id,
34
+ # client_secret, token_auth_method, token_endpoint, resource,
35
+ # redirect_uri.
36
+ #
37
+ # With no scope given, the server's advertised scopes_supported are
38
+ # requested, and offline_access rides along when the authorization
39
+ # server supports it, which is what earns a refresh token from
40
+ # providers that require it.
41
+ def start(url:, client_name:, redirect_uri:, scope: nil,
42
+ client_id: nil, client_secret: nil)
43
+ resource = canonical(url)
44
+ resource_metadata = resource_metadata_for(url)
45
+ metadata = server_metadata(Array(resource_metadata["authorization_servers"]).first)
46
+ validate_endpoints(metadata)
47
+ registration = register(metadata, client_name, redirect_uri, client_id, client_secret)
48
+ verifier = SecureRandom.urlsafe_base64(48)
49
+ state = SecureRandom.urlsafe_base64(32)
50
+ grant = { client_id: registration["client_id"], redirect_uri: redirect_uri,
51
+ verifier: verifier, state: state, resource: resource,
52
+ scope: resolve_scope(scope, resource_metadata, metadata) }
53
+ {
54
+ "authorize_url" => authorize_url(metadata, grant),
55
+ "state" => state, "code_verifier" => verifier,
56
+ "client_id" => registration["client_id"],
57
+ "client_secret" => registration["client_secret"],
58
+ "token_auth_method" => registration["token_endpoint_auth_method"],
59
+ "token_endpoint" => metadata.fetch("token_endpoint"),
60
+ "resource" => resource, "redirect_uri" => redirect_uri
61
+ }
62
+ end
63
+
64
+ # Exchange the callback's code for tokens.
65
+ def complete(code:, code_verifier:, client_id:, token_endpoint:, resource:,
66
+ redirect_uri:, client_secret: nil, token_auth_method: nil, **)
67
+ form = { "grant_type" => "authorization_code", "code" => code,
68
+ "code_verifier" => code_verifier, "client_id" => client_id,
69
+ "redirect_uri" => redirect_uri, "resource" => resource }
70
+ token_request(token_endpoint, form, client_secret, token_auth_method)
71
+ end
72
+
73
+ # Trade a refresh token for a fresh set; OAuth 2.1 rotates refresh
74
+ # tokens, so persist the returned one.
75
+ def refresh(refresh_token:, client_id:, token_endpoint:, resource:,
76
+ client_secret: nil, token_auth_method: nil, **)
77
+ form = { "grant_type" => "refresh_token", "refresh_token" => refresh_token,
78
+ "client_id" => client_id, "resource" => resource }
79
+ token_request(token_endpoint, form, client_secret, token_auth_method)
80
+ end
81
+
82
+ # -- discovery ---------------------------------------------------------
83
+
84
+ # RFC 9728: a 401's WWW-Authenticate names the resource metadata URL;
85
+ # servers that skip the header serve the well-known path.
86
+ def resource_metadata_for(url)
87
+ metadata_url = challenge_metadata_url(url) || well_known_resource_url(url)
88
+ document = get_json(metadata_url)
89
+ if Array(document["authorization_servers"]).empty?
90
+ raise Error, "#{metadata_url} names no authorization servers"
91
+ end
92
+
93
+ document
94
+ end
95
+
96
+ def challenge_metadata_url(url)
97
+ uri = URI(url)
98
+ response = http(uri) do |connection|
99
+ request = Net::HTTP::Post.new(uri)
100
+ request["Accept"] = "application/json, text/event-stream"
101
+ request["Content-Type"] = "application/json"
102
+ request.body = JSON.generate({ jsonrpc: "2.0", id: 0, method: "ping" })
103
+ connection.request(request)
104
+ end
105
+ challenge = response["WWW-Authenticate"].to_s
106
+ challenge[/resource_metadata="([^"]+)"/i, 1]
107
+ end
108
+
109
+ def well_known_resource_url(url)
110
+ uri = URI(url)
111
+ path = uri.path.chomp("/")
112
+ origin = "#{uri.scheme}://#{uri.host}:#{uri.port}"
113
+ "#{origin}/.well-known/oauth-protected-resource#{path unless path.empty?}"
114
+ end
115
+
116
+ # RFC 8414 metadata, with the OpenID Connect path as a fallback since
117
+ # large providers often serve only that document.
118
+ def server_metadata(authority)
119
+ uri = URI(authority)
120
+ origin = "#{uri.scheme}://#{uri.host}:#{uri.port}"
121
+ path = uri.path.chomp("/")
122
+ candidates = ["#{origin}/.well-known/oauth-authorization-server#{path unless path.empty?}",
123
+ "#{origin}#{path}/.well-known/openid-configuration"]
124
+ candidates.each do |candidate|
125
+ document = try_json(candidate)
126
+ return document if document&.key?("token_endpoint")
127
+ end
128
+ raise Error, "no authorization server metadata at #{authority}"
129
+ end
130
+
131
+ # RFC 7591 dynamic registration, as the application. Servers without a
132
+ # registration endpoint require a pre-registered client id. The
133
+ # returned hash keeps the token endpoint auth method the server
134
+ # granted, so token requests authenticate the way it expects.
135
+ def register(metadata, client_name, redirect_uri, client_id, client_secret)
136
+ return { "client_id" => client_id, "client_secret" => client_secret } if client_id
137
+
138
+ endpoint = metadata["registration_endpoint"]
139
+ unless endpoint
140
+ raise Error, "the server does not offer dynamic client registration; " \
141
+ "pass client_id:/client_secret: from a manual registration"
142
+ end
143
+
144
+ registration = post_json(endpoint, {
145
+ "client_name" => client_name,
146
+ "redirect_uris" => [redirect_uri],
147
+ "grant_types" => %w[authorization_code refresh_token],
148
+ "response_types" => ["code"],
149
+ "token_endpoint_auth_method" => "client_secret_post"
150
+ })
151
+ {
152
+ "client_id" => presence(registration["client_id"]) ||
153
+ raise(Error, "registration returned no client_id"),
154
+ "client_secret" => presence(registration["client_secret"]),
155
+ "token_endpoint_auth_method" => presence(registration["token_endpoint_auth_method"])
156
+ }
157
+ end
158
+
159
+ # No scope given: request what the resource advertises, and add
160
+ # offline_access when the authorization server supports it (that is
161
+ # what earns a refresh token from providers that require it). An
162
+ # unsupported offline_access is stripped rather than sent blind.
163
+ def resolve_scope(scope, resource_metadata, metadata)
164
+ scopes = scope.to_s.split
165
+ scopes = Array(resource_metadata["scopes_supported"]) if scopes.empty?
166
+ supported = Array(metadata["scopes_supported"])
167
+ if supported.include?("offline_access")
168
+ scopes |= ["offline_access"]
169
+ else
170
+ scopes -= ["offline_access"]
171
+ end
172
+ scopes.empty? ? nil : scopes.join(" ")
173
+ end
174
+
175
+ # The spec requires authorization server endpoints over HTTPS;
176
+ # loopback stays allowed for development.
177
+ def validate_endpoints(metadata)
178
+ %w[authorization_endpoint token_endpoint registration_endpoint].each do |key|
179
+ value = metadata[key] or next
180
+ uri = URI(value)
181
+ next if uri.scheme == "https" || %w[localhost 127.0.0.1 ::1].include?(uri.host)
182
+
183
+ raise Error, "#{key} #{value} is not HTTPS"
184
+ end
185
+ end
186
+
187
+ def presence(value)
188
+ value.to_s.strip.empty? ? nil : value
189
+ end
190
+
191
+ def authorize_url(metadata, grant)
192
+ challenge = Digest::SHA256.base64digest(grant[:verifier]).tr("+/", "-_").delete("=")
193
+ params = { "response_type" => "code", "client_id" => grant[:client_id],
194
+ "redirect_uri" => grant[:redirect_uri], "state" => grant[:state],
195
+ "code_challenge" => challenge, "code_challenge_method" => "S256",
196
+ "resource" => grant[:resource] }
197
+ params["scope"] = grant[:scope] if grant[:scope]
198
+ endpoint = URI(metadata.fetch("authorization_endpoint"))
199
+ endpoint.query = [endpoint.query, URI.encode_www_form(params)].compact.join("&")
200
+ endpoint.to_s
201
+ end
202
+
203
+ # -- plumbing ----------------------------------------------------------
204
+
205
+ # RFC 8707 canonical form: lowercase scheme and host, no fragment.
206
+ def canonical(url)
207
+ uri = URI(url)
208
+ uri.fragment = nil
209
+ uri.scheme = uri.scheme.downcase
210
+ uri.host = uri.host.downcase if uri.host
211
+ uri.to_s
212
+ end
213
+
214
+ def token_request(endpoint, form, client_secret, auth_method = nil)
215
+ basic = client_secret && auth_method == "client_secret_basic"
216
+ form = form.merge("client_secret" => client_secret) if client_secret && !basic
217
+ credentials = basic ? [form["client_id"], client_secret] : nil
218
+ payload = post_form(endpoint, form, basic_auth: credentials)
219
+ expires_in = payload["expires_in"]
220
+ {
221
+ "access_token" => payload.fetch("access_token"),
222
+ "refresh_token" => payload["refresh_token"],
223
+ "scope" => payload["scope"],
224
+ "expires_at" => expires_in ? Time.now.utc + expires_in.to_i : nil
225
+ }
226
+ end
227
+
228
+ def get_json(url)
229
+ uri = URI(url)
230
+ response = http(uri) { |connection| connection.request(Net::HTTP::Get.new(uri)) }
231
+ raise Error, "GET #{url} answered #{response.code}" unless response.code.to_i == 200
232
+
233
+ JSON.parse(response.body)
234
+ end
235
+
236
+ def try_json(url)
237
+ get_json(url)
238
+ rescue Error, JSON::ParserError
239
+ nil
240
+ end
241
+
242
+ def post_json(url, body)
243
+ uri = URI(url)
244
+ response = http(uri) do |connection|
245
+ request = Net::HTTP::Post.new(uri)
246
+ request["Content-Type"] = "application/json"
247
+ request.body = JSON.generate(body)
248
+ connection.request(request)
249
+ end
250
+ unless %w[200 201].include?(response.code)
251
+ raise Error, "POST #{url} answered #{response.code}: #{response.body.to_s[0, 200]}"
252
+ end
253
+
254
+ JSON.parse(response.body)
255
+ end
256
+
257
+ def post_form(url, form, basic_auth: nil)
258
+ uri = URI(url)
259
+ response = http(uri) do |connection|
260
+ request = Net::HTTP::Post.new(uri)
261
+ request["Content-Type"] = "application/x-www-form-urlencoded"
262
+ request["Accept"] = "application/json"
263
+ request.basic_auth(*basic_auth) if basic_auth
264
+ request.body = URI.encode_www_form(form)
265
+ connection.request(request)
266
+ end
267
+ payload = begin
268
+ JSON.parse(response.body)
269
+ rescue StandardError
270
+ {}
271
+ end
272
+ unless response.code.to_i == 200
273
+ reason = payload["error_description"] || payload["error"] || response.body.to_s[0, 200]
274
+ raise Error, "token request failed (#{response.code}): #{reason}"
275
+ end
276
+
277
+ payload
278
+ end
279
+
280
+ def http(uri, &)
281
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
282
+ open_timeout: 15, read_timeout: 30, &)
283
+ end
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/wait"
4
+ require "json"
5
+
6
+ module Mistri
7
+ module MCP
8
+ # The two ways an MCP conversation travels. A wire takes one JSON-RPC
9
+ # payload, yields every decoded message the server sends back until the
10
+ # payload's own response arrives, and knows nothing about MCP semantics;
11
+ # the Client owns those.
12
+ module Wires
13
+ # Streamable HTTP: requests POST to one endpoint, responses arrive as
14
+ # JSON or an SSE stream. Sessions and bearer auth live here.
15
+ class Http
16
+ def initialize(url:, token:, headers:, open_timeout:, read_timeout:)
17
+ uri = URI(url)
18
+ @path = uri.path.empty? ? "/" : uri.path
19
+ @path = "#{@path}?#{uri.query}" if uri.query
20
+ @transport = Transport.new(origin: "#{uri.scheme}://#{uri.host}:#{uri.port}",
21
+ open_timeout: open_timeout, read_timeout: read_timeout)
22
+ @token = token
23
+ @headers = headers
24
+ @session_id = nil
25
+ @protocol_version = nil
26
+ end
27
+
28
+ attr_writer :protocol_version
29
+
30
+ def call(payload, &)
31
+ meta = @transport.post_either(@path, body: payload, headers: request_headers, &)
32
+ capture_session(meta)
33
+ nil
34
+ end
35
+
36
+ def notify(payload)
37
+ discard = ->(_record) {}
38
+ @transport.post_either(@path, body: payload, headers: request_headers, &discard)
39
+ nil
40
+ end
41
+
42
+ def session? = !@session_id.nil?
43
+
44
+ def refreshable? = @token.respond_to?(:call)
45
+
46
+ def reset_session = @session_id = nil
47
+
48
+ def close = @transport.close
49
+
50
+ private
51
+
52
+ def request_headers
53
+ headers = { "Accept" => "application/json, text/event-stream" }
54
+ headers.merge!(@headers)
55
+ headers["Authorization"] = "Bearer #{resolve_token}" if @token
56
+ headers["Mcp-Session-Id"] = @session_id if @session_id
57
+ headers["MCP-Protocol-Version"] = @protocol_version if @protocol_version
58
+ headers
59
+ end
60
+
61
+ def resolve_token
62
+ @token.respond_to?(:call) ? @token.call : @token
63
+ end
64
+
65
+ def capture_session(meta)
66
+ session = meta && meta["mcp-session-id"]
67
+ @session_id = session if session
68
+ end
69
+ end
70
+
71
+ # Stdio: a spawned child process, one JSON-RPC message per line, with
72
+ # credentials in its environment, as the spec prescribes for local
73
+ # servers. Its stderr stays attached for honest local debugging.
74
+ class Stdio
75
+ def initialize(command:, env: {}, read_timeout: 120)
76
+ @command = Array(command).map(&:to_s)
77
+ @env = env.transform_keys(&:to_s).transform_values(&:to_s)
78
+ @read_timeout = read_timeout
79
+ @pid = nil
80
+ end
81
+
82
+ def call(payload)
83
+ spawn_server unless @pid
84
+ write(payload)
85
+ loop do
86
+ record = read_record
87
+ yield record
88
+ break if record.is_a?(Hash) && record["id"] == payload[:id]
89
+ end
90
+ nil
91
+ end
92
+
93
+ def notify(payload)
94
+ spawn_server unless @pid
95
+ write(payload)
96
+ nil
97
+ end
98
+
99
+ def session? = false
100
+
101
+ def refreshable? = false
102
+
103
+ def reset_session = nil
104
+
105
+ def protocol_version=(_version); end
106
+
107
+ def close
108
+ return unless @pid
109
+
110
+ [@stdin, @stdout].each { |io| io.close unless io.closed? }
111
+ terminate
112
+ @pid = nil
113
+ end
114
+
115
+ private
116
+
117
+ def spawn_server
118
+ child_in, @stdin = IO.pipe
119
+ @stdout, child_out = IO.pipe
120
+ @pid = Process.spawn(@env, *@command, in: child_in, out: child_out)
121
+ child_in.close
122
+ child_out.close
123
+ end
124
+
125
+ def write(payload)
126
+ @stdin.write("#{JSON.generate(payload)}\n")
127
+ @stdin.flush
128
+ rescue Errno::EPIPE
129
+ raise Error, "the MCP server closed its input"
130
+ end
131
+
132
+ # The spec requires stdout to carry only protocol messages, so a
133
+ # line that is not one is corruption worth failing loudly on.
134
+ def read_record
135
+ loop do
136
+ ready = @stdout.wait_readable(@read_timeout)
137
+ raise Error, "timed out waiting for the MCP server" unless ready
138
+
139
+ line = @stdout.gets
140
+ raise Error, "the MCP server exited" if line.nil?
141
+ next if line.strip.empty?
142
+
143
+ return JSON.parse(line)
144
+ end
145
+ rescue JSON::ParserError
146
+ raise Error, "the MCP server wrote non-protocol output on stdout"
147
+ end
148
+
149
+ def terminate
150
+ Process.kill("TERM", @pid)
151
+ 20.times do
152
+ return if Process.waitpid(@pid, Process::WNOHANG)
153
+
154
+ sleep(0.05)
155
+ end
156
+ Process.kill("KILL", @pid)
157
+ Process.waitpid(@pid)
158
+ rescue Errno::ESRCH, Errno::ECHILD
159
+ nil
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
data/lib/mistri/mcp.rb ADDED
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mistri
6
+ # Bridge Model Context Protocol servers into Mistri tools: list a server's
7
+ # tools, hand them to an agent, and everything the harness already does
8
+ # composes — approval gates on third-party write tools, retries, sub-agent
9
+ # pools, the ui channel.
10
+ #
11
+ # client = Mistri::MCP::Client.new(url: "https://mcp.linear.app/mcp",
12
+ # token: -> { connection.fresh_token })
13
+ # agent = Mistri.agent("claude-opus-4-8",
14
+ # tools: Mistri::MCP.tools(client, prefix: "linear"))
15
+ #
16
+ # The bridge is duck-typed: any client responding to tools (an array of
17
+ # {"name", "description", "inputSchema"} hashes) and call_tool(name, args)
18
+ # bridges the same way, so the official mcp gem's client plugs in too.
19
+ module MCP
20
+ # A protocol-level failure: a JSON-RPC error, a missing response, an
21
+ # unsupported negotiation.
22
+ class Error < Mistri::Error
23
+ attr_reader :code
24
+
25
+ def initialize(message = nil, code: nil)
26
+ @code = code
27
+ super(message)
28
+ end
29
+ end
30
+
31
+ # The server expired this client's session (a 404 with a session
32
+ # attached); the spec says start a fresh one, and Client does.
33
+ class SessionExpired < Error; end
34
+
35
+ module_function
36
+
37
+ # The server's tools as Mistri tools. allow/deny filter by remote name,
38
+ # prefix namespaces local names ("linear__create_issue") against
39
+ # collisions, and gates marks tools needing human approval
40
+ # (gates: { "create_issue" => true }, or needs_approval: for all).
41
+ def tools(client, allow: nil, deny: [], prefix: nil, needs_approval: false, gates: {})
42
+ listed = client.tools
43
+ listed = listed.select { |tool| allow.include?(tool["name"]) } if allow
44
+ listed = listed.reject { |tool| deny.include?(tool["name"]) }
45
+ listed.map do |tool|
46
+ bridge(client, tool, prefix: prefix, gate: gates.fetch(tool["name"], needs_approval))
47
+ end
48
+ end
49
+
50
+ def bridge(client, spec, prefix: nil, gate: false)
51
+ remote = spec.fetch("name")
52
+ local = prefix ? "#{prefix}__#{remote}" : remote
53
+ Tool.define(local, spec["description"].to_s,
54
+ input_schema: spec["inputSchema"] || Tool::EMPTY_SCHEMA,
55
+ needs_approval: gate) do |args|
56
+ answer(client.call_tool(remote, args || {}))
57
+ end
58
+ end
59
+
60
+ # An MCP result becomes model-readable content: text joins, images ride
61
+ # as image blocks, and isError answers in band so the model can react.
62
+ def answer(result)
63
+ blocks = Array(result["content"]).map { |block| convert(block) }
64
+ if result["isError"]
65
+ text = blocks.grep(String).join("\n")
66
+ return "MCP tool error: #{text.empty? ? "unknown error" : text}"
67
+ end
68
+ if blocks.empty? && result["structuredContent"]
69
+ return JSON.generate(result["structuredContent"])
70
+ end
71
+ return blocks.join("\n") if blocks.all?(String)
72
+
73
+ blocks
74
+ end
75
+
76
+ def convert(block)
77
+ case block["type"]
78
+ when "text" then block["text"].to_s
79
+ when "image"
80
+ Content::Image.from_bytes(block["data"].to_s.unpack1("m"),
81
+ mime_type: block["mimeType"] || "image/png")
82
+ when "resource" then resource_text(block["resource"] || {})
83
+ when "resource_link" then "[resource: #{block["uri"]}]"
84
+ else "[unsupported #{block["type"]} content]"
85
+ end
86
+ end
87
+
88
+ def resource_text(resource)
89
+ resource["text"] || "[resource: #{resource["uri"]}]"
90
+ end
91
+ end
92
+ end
93
+
94
+ require_relative "mcp/wires"
95
+ require_relative "mcp/client"
96
+ require_relative "mcp/oauth"
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # A periodic reminder for long agentic runs: models drift from their
5
+ # instructions as turns accumulate, and a short reminder at the tail of
6
+ # the context, where attention is strongest, pulls them back. It rides
7
+ # transform_context, so it appears fresh on the wire each time it is due
8
+ # and never persists to the session.
9
+ #
10
+ # agent = Mistri.agent("claude-opus-4-8", tools: tools,
11
+ # transform_context: Mistri::Reminder.every(
12
+ # 3, "Stay on gifting. Verify with tools before claiming.",
13
+ # ))
14
+ #
15
+ # Due is counted in completed assistant turns: the first reminder lands
16
+ # once `after` turns have finished (default: one full interval), then
17
+ # every `interval` turns.
18
+ class Reminder
19
+ def self.every(interval, text, after: nil)
20
+ new(interval: interval, text: text, after: after)
21
+ end
22
+
23
+ def initialize(interval:, text:, after: nil)
24
+ @interval = [interval.to_i, 1].max
25
+ @after = (after || @interval).to_i
26
+ @body = "<system-reminder>\n#{text}\n</system-reminder>"
27
+ end
28
+
29
+ def call(messages)
30
+ turns = messages.count(&:assistant?)
31
+ return messages unless turns >= @after && ((turns - @after) % @interval).zero?
32
+
33
+ messages + [Message.user(@body)]
34
+ end
35
+ end
36
+ end
data/lib/mistri/result.rb CHANGED
@@ -9,9 +9,11 @@ module Mistri
9
9
  #
10
10
  # Reads delegate to the final message, so result.text works whether the run
11
11
  # completed or suspended.
12
- # output is a task's validated value, nil on plain runs.
13
- Result = Data.define(:message, :status, :pending, :output) do
14
- def initialize(message:, status:, pending: [], output: nil)
12
+ # output is a task's validated value, nil on plain runs. usage is the
13
+ # run's own accounting: every persisted turn plus compaction calls, summed
14
+ # (a resumed run counts from the resume; task sums across its fix passes).
15
+ Result = Data.define(:message, :status, :pending, :output, :usage) do
16
+ def initialize(message:, status:, pending: [], output: nil, usage: Usage.zero)
15
17
  super
16
18
  end
17
19
 
data/lib/mistri/tool.rb CHANGED
@@ -13,7 +13,7 @@ module Mistri
13
13
  # bare empty hash.
14
14
  EMPTY_SCHEMA = { type: "object", properties: {} }.freeze
15
15
 
16
- attr_reader :name, :description, :input_schema
16
+ attr_reader :name, :description, :input_schema, :timeout
17
17
 
18
18
  # Define a tool. Give the argument shape as a raw JSON Schema hash via
19
19
  # input_schema:, or build it in Ruby with a schema: block.
@@ -28,7 +28,7 @@ module Mistri
28
28
  end
29
29
 
30
30
  def initialize(name:, description:, input_schema: EMPTY_SCHEMA, eager_input_streaming: false,
31
- needs_approval: false, &handler)
31
+ needs_approval: false, timeout: nil, &handler)
32
32
  raise ArgumentError, "tool #{name.inspect} needs a handler block" unless handler
33
33
 
34
34
  @name = name.to_s
@@ -36,6 +36,7 @@ module Mistri
36
36
  @input_schema = input_schema
37
37
  @eager_input_streaming = eager_input_streaming
38
38
  @needs_approval = needs_approval
39
+ @timeout = timeout
39
40
  @handler = handler
40
41
  end
41
42
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "timeout"
4
+
3
5
  module Mistri
4
6
  # Runs a turn's tool calls and returns their results in the order the model
5
7
  # emitted them, regardless of completion order. Independent calls run
@@ -23,7 +25,10 @@ module Mistri
23
25
  calls.each_with_index { |call, index| queue << [call, index] }
24
26
  workers = max_concurrency.clamp(1, calls.length)
25
27
  Array.new(workers) { worker(queue, results, tools_by_name, context) }.each(&:join)
26
- calls.zip(results).map { |call, result| [call, result || INTERRUPTED] }
28
+ calls.zip(results).map do |call, entry|
29
+ value, seconds = entry || [INTERRUPTED, nil]
30
+ [call, value, seconds]
31
+ end
27
32
  end
28
33
 
29
34
  def worker(queue, results, tools_by_name, context)
@@ -34,8 +39,14 @@ module Mistri
34
39
  rescue ThreadError
35
40
  break
36
41
  end
37
- interrupted = context.signal&.aborted?
38
- results[index] = interrupted ? INTERRUPTED : run_one(call, tools_by_name, context)
42
+ if context.signal&.aborted?
43
+ results[index] = [INTERRUPTED, nil]
44
+ next
45
+ end
46
+
47
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
+ value = run_one(call, tools_by_name, context)
49
+ results[index] = [value, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started]
39
50
  end
40
51
  end
41
52
  end
@@ -44,11 +55,21 @@ module Mistri
44
55
  tool = tools_by_name[call.name]
45
56
  return "Error: unknown tool #{call.name.inspect}" unless tool
46
57
 
47
- with_rails_executor { tool.call(call.arguments, context) }
58
+ with_rails_executor { invoke(tool, call, context) }
48
59
  rescue StandardError => e
49
60
  "Error running tool #{call.name.inspect}: #{e.class}: #{e.message}"
50
61
  end
51
62
 
63
+ # A tool with a timeout answers in band when it stalls, so one hung
64
+ # handler cannot stall the whole run.
65
+ def invoke(tool, call, context)
66
+ return tool.call(call.arguments, context) unless tool.timeout
67
+
68
+ Timeout.timeout(tool.timeout) { tool.call(call.arguments, context) }
69
+ rescue Timeout::Error
70
+ "Error running tool #{call.name.inspect}: timed out after #{tool.timeout}s"
71
+ end
72
+
52
73
  # Concurrent tools share the caller's sink; sinks are not required to be
53
74
  # thread-safe, so forwarded events serialize here.
54
75
  def thread_safe(emit)