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 +4 -4
- data/CHANGELOG.md +60 -0
- data/README.md +60 -2
- data/lib/generators/mistri/mcp/mcp_generator.rb +57 -0
- data/lib/generators/mistri/mcp/templates/migration.rb.tt +27 -0
- data/lib/generators/mistri/mcp/templates/model.rb.tt +63 -0
- data/lib/mistri/agent.rb +68 -19
- data/lib/mistri/event.rb +7 -3
- data/lib/mistri/mcp/client.rb +156 -0
- data/lib/mistri/mcp/oauth.rb +286 -0
- data/lib/mistri/mcp/wires.rb +164 -0
- data/lib/mistri/mcp.rb +96 -0
- data/lib/mistri/reminder.rb +36 -0
- data/lib/mistri/result.rb +5 -3
- data/lib/mistri/tool.rb +3 -2
- data/lib/mistri/tool_executor.rb +25 -4
- data/lib/mistri/transport.rb +41 -0
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri.rb +2 -0
- metadata +14 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e553e955f2a1263afed1b26889dbf04773916dbd6f33f9229e6db2b729dd655b
|
|
4
|
+
data.tar.gz: 98b95079d9f91b068580ac2708c362b1317452378eba99fe7643e301660ad89d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
<
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|